diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ba693eb6d5b..46efa06189ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,13 @@ Code v99.99.999 ## Unreleased +### Added + +- `--disable-file-cache` keeps workspace files out of the browser cache. It + stops the webview service worker from storing them in Cache Storage and + serves them from `/vscode-remote-resource` with `Cache-Control: no-store`. + Extension files stay cached. + ## [4.143.1](https://github.com/coder/code-server/releases/tag/v4.143.1) - 2026-10-23 Code v1.143.1 diff --git a/patches/disable-file-cache.diff b/patches/disable-file-cache.diff new file mode 100644 index 000000000000..4aca32a0af1c --- /dev/null +++ b/patches/disable-file-cache.diff @@ -0,0 +1,188 @@ +Add option to keep workspace files out of the browser cache + +This patch adds support for a new CLI flag called `--disable-file-cache`. When +set, workspace files are not stored on the client disk through two caches: + +- The webview resource loader sends no ETag for a resource outside the + webview's own extension, and the webview service worker only stores responses + that have an ETag, so these resources stay out of Cache Storage. +- `/vscode-remote-resource` responds with `Cache-Control: no-store` for files + outside the extension directories. + +Extension resources keep their ETags so webviews still load extension code from +the cache. The default value for this is `false`. + +To test, start code-server with `--disable-file-cache`, open a Markdown file +that embeds an image from the workspace, and open the preview. In the browser +dev tools, the `vscode-resource-cache-*` Cache Storage of the webview service +worker should have no entry for the image, and a request to +`/vscode-remote-resource?path=` should respond with +`Cache-Control: no-store`. + +Index: code-server/lib/vscode/src/vs/server/node/remoteExtensionHostAgentServer.ts +=================================================================== +--- code-server.orig/lib/vscode/src/vs/server/node/remoteExtensionHostAgentServer.ts ++++ code-server/lib/vscode/src/vs/server/node/remoteExtensionHostAgentServer.ts +@@ -177,15 +177,14 @@ class RemoteExtensionHostAgentServer ext + + const requestOrigin = req.headers['origin']; + const responseHeaders = getRemoteResourceResponseHeaders(requestOrigin, origin => this._webEndpointOriginChecker.matches(origin)); +- if (this._environmentService.isBuilt) { +- if (isEqualOrParent(filePath, this._environmentService.builtinExtensionsPath, !platform.isLinux) +- || isEqualOrParent(filePath, this._environmentService.extensionsPath, !platform.isLinux) +- ) { +- responseHeaders['Cache-Control'] = 'public, max-age=31536000'; +- } ++ const isExtensionFile = isEqualOrParent(filePath, this._environmentService.builtinExtensionsPath, !platform.isLinux) ++ || isEqualOrParent(filePath, this._environmentService.extensionsPath, !platform.isLinux); ++ if (this._environmentService.isBuilt && isExtensionFile) { ++ responseHeaders['Cache-Control'] = 'public, max-age=31536000'; + } + +- return serveFile(filePath, CacheControl.ETAG, this._logService, req, res, responseHeaders); ++ const cacheControl = this._environmentService.args['disable-file-cache'] && !isExtensionFile ? CacheControl.NO_CACHING : CacheControl.ETAG; ++ return serveFile(filePath, cacheControl, this._logService, req, res, responseHeaders); + } + + // workbench web UI +Index: code-server/lib/vscode/src/vs/server/node/serverEnvironmentService.ts +=================================================================== +--- code-server.orig/lib/vscode/src/vs/server/node/serverEnvironmentService.ts ++++ code-server/lib/vscode/src/vs/server/node/serverEnvironmentService.ts +@@ -37,6 +37,7 @@ export const serverOptions: OptionDescri + 'auth': { type: 'string' }, + 'disable-file-downloads': { type: 'boolean' }, + 'disable-file-uploads': { type: 'boolean' }, ++ 'disable-file-cache': { type: 'boolean' }, + 'disable-getting-started-override': { type: 'boolean' }, + 'locale': { type: 'string' }, + 'link-protection-trusted-domains': { type: 'string[]' }, +@@ -139,6 +140,7 @@ export interface ServerParsedArgs { + 'auth'?: string; + 'disable-file-downloads'?: boolean; + 'disable-file-uploads'?: boolean; ++ 'disable-file-cache'?: boolean; + 'disable-getting-started-override'?: boolean, + 'locale'?: string + 'link-protection-trusted-domains'?: string[], +Index: code-server/lib/vscode/src/vs/server/node/webClientServer.ts +=================================================================== +--- code-server.orig/lib/vscode/src/vs/server/node/webClientServer.ts ++++ code-server/lib/vscode/src/vs/server/node/webClientServer.ts +@@ -442,6 +442,7 @@ export class WebClientServer { + userDataPath: this._environmentService.userDataPath, + isEnabledFileDownloads: !this._environmentService.args['disable-file-downloads'], + isEnabledFileUploads: !this._environmentService.args['disable-file-uploads'], ++ isEnabledFileCache: !this._environmentService.args['disable-file-cache'], + isEnabledCoderGettingStarted: !this._environmentService.args['disable-getting-started-override'], + _wrapWebWorkerExtHostInIframe, + developmentOptions: { enableSmokeTestDriver: this._environmentService.args['enable-smoke-test-driver'] ? true : undefined, logLevel: this._logService.getLevel() }, +Index: code-server/lib/vscode/src/vs/workbench/browser/web.api.ts +=================================================================== +--- code-server.orig/lib/vscode/src/vs/workbench/browser/web.api.ts ++++ code-server/lib/vscode/src/vs/workbench/browser/web.api.ts +@@ -330,6 +330,11 @@ export interface IWorkbenchConstructionO + readonly isEnabledFileUploads?: boolean + + /** ++ * Whether the browser may cache workspace files. ++ */ ++ readonly isEnabledFileCache?: boolean ++ ++ /** + * Whether to use Coder's custom Getting Started text. + */ + readonly isEnabledCoderGettingStarted?: boolean +Index: code-server/lib/vscode/src/vs/workbench/contrib/webview/browser/webviewElement.ts +=================================================================== +--- code-server.orig/lib/vscode/src/vs/workbench/contrib/webview/browser/webviewElement.ts ++++ code-server/lib/vscode/src/vs/workbench/contrib/webview/browser/webviewElement.ts +@@ -15,7 +15,7 @@ import { Lazy } from '../../../../base/c + import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; + import { COI, Schemas } from '../../../../base/common/network.js'; + import { observableValue } from '../../../../base/common/observable.js'; +-import { isEqualAuthority } from '../../../../base/common/resources.js'; ++import { isEqualAuthority, isEqualOrParent } from '../../../../base/common/resources.js'; + import { listenStream } from '../../../../base/common/stream.js'; + import { URI } from '../../../../base/common/uri.js'; + import { generateUuid } from '../../../../base/common/uuid.js'; +@@ -32,6 +32,7 @@ import { INotificationService } from '.. + import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; + import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js'; + import { WebviewPortMappingManager } from '../../../../platform/webview/common/webviewPortMapping.js'; ++import { IBrowserWorkbenchEnvironmentService } from '../../../services/environment/browser/environmentService.js'; + import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; + import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; + import { decodeAuthority, webviewGenericCspSource, webviewRootResourceAuthority } from '../common/webview.js'; +@@ -864,6 +865,8 @@ export class WebviewElement extends Disp + const rangeHeader = range + ? `bytes ${range.start}-${rangeEnd}/${result.size}` + : undefined; ++ // The service worker only stores responses that have an etag. ++ const etag = this._isFileCacheAllowed(uri) ? result.etag : undefined; + if (WebviewElement._supportsTransferableStreams.value) { + let controller: ReadableStreamDefaultController> | undefined; + let closed = false; +@@ -915,7 +918,7 @@ export class WebviewElement extends Disp + status: range ? 206 : 200, + path: uri.path, + mime: result.mimeType, +- etag: result.etag, ++ etag, + mtime: result.mtime, + range: rangeHeader, + stream, +@@ -927,7 +930,7 @@ export class WebviewElement extends Disp + status: range ? 206 : 200, + path: uri.path, + mime: result.mimeType, +- etag: result.etag, ++ etag, + mtime: result.mtime, + range: rangeHeader, + }); +@@ -984,6 +987,14 @@ export class WebviewElement extends Disp + }); + } + ++ private _isFileCacheAllowed(uri: URI): boolean { ++ if ((this._environmentService as IBrowserWorkbenchEnvironmentService).isEnabledFileCache) { ++ return true; ++ } ++ const extensionLocation = this.extension?.location; ++ return !!extensionLocation && isEqualOrParent(uri, extensionLocation); ++ } ++ + private async localLocalhost(id: string, origin: string) { + const authority = this._environmentService.remoteAuthority; + const resolveAuthority = authority ? await this._remoteAuthorityResolverService.resolveAuthority(authority) : undefined; +Index: code-server/lib/vscode/src/vs/workbench/services/environment/browser/environmentService.ts +=================================================================== +--- code-server.orig/lib/vscode/src/vs/workbench/services/environment/browser/environmentService.ts ++++ code-server/lib/vscode/src/vs/workbench/services/environment/browser/environmentService.ts +@@ -49,6 +49,11 @@ export interface IBrowserWorkbenchEnviro + readonly isEnabledFileUploads?: boolean; + + /** ++ * Allow the browser to cache workspace files. ++ */ ++ readonly isEnabledFileCache?: boolean; ++ ++ /** + * Enable Coder's custom getting started text. + */ + readonly isEnabledCoderGettingStarted?: boolean; +@@ -145,6 +150,13 @@ export class BrowserWorkbenchEnvironment + return this.options.isEnabledFileUploads; + } + ++ get isEnabledFileCache(): boolean { ++ if (typeof this.options.isEnabledFileCache === "undefined") { ++ throw new Error('isEnabledFileCache was not provided to the browser'); ++ } ++ return this.options.isEnabledFileCache; ++ } ++ + get isEnabledCoderGettingStarted(): boolean { + if (typeof this.options.isEnabledCoderGettingStarted === "undefined") { + throw new Error('isEnabledCoderGettingStarted was not provided to the browser'); diff --git a/patches/series b/patches/series index b102e9d2c5c6..3fe2274d6a55 100644 --- a/patches/series +++ b/patches/series @@ -27,3 +27,4 @@ app-name.diff remote-storage.diff remote-secret-storage.diff csp-hashes.diff +disable-file-cache.diff diff --git a/src/node/cli.ts b/src/node/cli.ts index 6597a3b6ae6b..8ed868577356 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -51,6 +51,7 @@ export interface UserProvidedCodeArgs { "disable-update-check"?: boolean "disable-file-downloads"?: boolean "disable-file-uploads"?: boolean + "disable-file-cache"?: boolean "disable-workspace-trust"?: boolean "disable-getting-started-override"?: boolean "disable-proxy"?: boolean @@ -193,6 +194,10 @@ export const options: Options> = { type: "boolean", description: "Disable file uploads.", }, + "disable-file-cache": { + type: "boolean", + description: "Keep workspace files out of the browser cache.", + }, "disable-workspace-trust": { type: "boolean", description: "Disable Workspace Trust feature. This switch only affects the current session.", diff --git a/test/e2e/fileCache.test.ts b/test/e2e/fileCache.test.ts new file mode 100644 index 000000000000..334ce4b8d190 --- /dev/null +++ b/test/e2e/fileCache.test.ts @@ -0,0 +1,132 @@ +import { promises as fs } from "fs" +import * as path from "path" +import { Page } from "playwright" +import { clean, getMaybeProxiedCodeServer } from "../utils/helpers" +import { describe, test, expect } from "./baseFixture" +import { CodeServerPage } from "./models/CodeServer" + +// A 1x1 PNG. +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", +) + +/** + * Open a Markdown preview that embeds a PNG from the workspace and wait for + * the image to render. + */ +async function previewMarkdownWithImage(codeServerPage: CodeServerPage, imageName: string): Promise { + const dir = await codeServerPage.workspaceDir + await fs.writeFile(path.join(dir, imageName), png) + const markdown = path.join(dir, `${imageName}.md`) + await fs.writeFile(markdown, `# Preview\n\n![workspace image](${imageName})\n`) + await codeServerPage.openFile(markdown) + await codeServerPage.executeCommandViaMenus("Markdown: Open Preview to the Side") + await codeServerPage.waitForTab(`Preview ${markdown}`) + + const image = codeServerPage.page + .frameLocator("iframe.webview.ready") + .frameLocator("#active-frame") + .locator(`img[alt="workspace image"]`) + await expect.poll(() => image.evaluate((img: HTMLImageElement) => img.naturalWidth)).toBeGreaterThan(0) + return codeServerPage.page +} + +/** + * URLs in the webview service worker's Cache Storage. + */ +async function cachedWebviewUrls(page: Page): Promise { + const frame = await (await page.locator("iframe.webview.ready").elementHandle())?.contentFrame() + if (!frame) { + throw new Error("webview frame not found") + } + return frame.evaluate(async () => { + const urls: string[] = [] + for (const name of await caches.keys()) { + if (name.startsWith("vscode-resource-cache-")) { + for (const request of await (await caches.open(name)).keys()) { + urls.push(request.url) + } + } + } + return urls + }) +} + +async function fetchRemoteResource(codeServerPage: CodeServerPage, file: string) { + const address = await getMaybeProxiedCodeServer(codeServerPage) + const url = new URL(`vscode-remote-resource?path=${encodeURIComponent(file)}`, address) + return codeServerPage.page.request.get(url.toString()) +} + +/** + * Write a workspace file and a file in the user extensions directory. + */ +async function writeResourceFiles(codeServerPage: CodeServerPage, name: string) { + const dir = await codeServerPage.workspaceDir + const workspaceFile = path.join(dir, `${name}-workspace.txt`) + const extensionDir = path.join(dir, "extensions", `${name}-extension`) + const extensionFile = path.join(extensionDir, "resource.txt") + await fs.writeFile(workspaceFile, "workspace") + await fs.mkdir(extensionDir, { recursive: true }) + await fs.writeFile(extensionFile, "extension") + return { workspaceFile, extensionFile } +} + +describe("File cache (enabled)", ["--disable-workspace-trust"], {}, async () => { + const testName = "file-cache-enabled" + test.beforeAll(async () => { + await clean(testName) + }) + + test("should cache workspace files in the webview Cache Storage", async ({ codeServerPage }) => { + const page = await previewMarkdownWithImage(codeServerPage, "cache-enabled.png") + await expect + .poll(async () => (await cachedWebviewUrls(page)).filter((url) => url.includes("cache-enabled.png"))) + .toHaveLength(1) + }) + + test("should serve /vscode-remote-resource with an ETag and without no-store", async ({ codeServerPage }) => { + const { workspaceFile, extensionFile } = await writeResourceFiles(codeServerPage, "enabled") + for (const file of [workspaceFile, extensionFile]) { + const response = await fetchRemoteResource(codeServerPage, file) + expect(response.status()).toBe(200) + expect(response.headers()["etag"]).toBeTruthy() + expect(response.headers()["cache-control"] ?? "").not.toContain("no-store") + } + }) +}) + +describe("File cache (disabled)", ["--disable-workspace-trust", "--disable-file-cache"], {}, async () => { + const testName = "file-cache-disabled" + test.beforeAll(async () => { + await clean(testName) + }) + + test("should keep workspace files out of the webview Cache Storage but cache extension files", async ({ + codeServerPage, + }) => { + const page = await previewMarkdownWithImage(codeServerPage, "cache-disabled.png") + // Cached extension files show that the flag, not a cache failure, keeps the image out. + await expect + .poll(async () => (await cachedWebviewUrls(page)).filter((url) => url.includes("markdown-language-features"))) + .not.toHaveLength(0) + expect((await cachedWebviewUrls(page)).filter((url) => url.includes("cache-disabled.png"))).toHaveLength(0) + }) + + test("should serve /vscode-remote-resource with no-store for workspace files and an ETag for extension files", async ({ + codeServerPage, + }) => { + const { workspaceFile, extensionFile } = await writeResourceFiles(codeServerPage, "disabled") + + const workspaceResponse = await fetchRemoteResource(codeServerPage, workspaceFile) + expect(workspaceResponse.status()).toBe(200) + expect(workspaceResponse.headers()["cache-control"]).toBe("no-store") + expect(workspaceResponse.headers()["etag"]).toBeUndefined() + + const extensionResponse = await fetchRemoteResource(codeServerPage, extensionFile) + expect(extensionResponse.status()).toBe(200) + expect(extensionResponse.headers()["etag"]).toBeTruthy() + expect(extensionResponse.headers()["cache-control"] ?? "").not.toContain("no-store") + }) +}) diff --git a/test/unit/node/cli.test.ts b/test/unit/node/cli.test.ts index d31a35fbe2d9..ba5ad6950629 100644 --- a/test/unit/node/cli.test.ts +++ b/test/unit/node/cli.test.ts @@ -109,6 +109,8 @@ describe("parser", () => { "--disable-file-downloads", + "--disable-file-cache", + "--disable-getting-started-override", "--disable-proxy", @@ -137,6 +139,7 @@ describe("parser", () => { cert: { value: path.resolve("path/to/cert"), }, + "disable-file-cache": true, "disable-file-downloads": true, "disable-getting-started-override": true, "disable-proxy": true,