diff --git a/src/devtoolsPanel.ts b/src/devtoolsPanel.ts index 96a1322d..7077c4a5 100644 --- a/src/devtoolsPanel.ts +++ b/src/devtoolsPanel.ts @@ -414,6 +414,12 @@ export class DevToolsPanel { const uri = await this.parseUrlToUri(url); + // The stylesheet url comes from the inspected page, so it must never be able to steer + // this write outside the developer's project. + if (uri && !this.isWithinTrustedRoot(uri)) { + return; + } + // Finally open and edit the document if it exists if (uri) { const textEditor = await this.openEditorFromUri(uri); @@ -539,6 +545,35 @@ export class DevToolsPanel { }); } + private getTrustedRoots(): string[] { + const folders = vscode.workspace.workspaceFolders; + if (folders && folders.length > 0) { + return folders.map(folder => folder.uri.fsPath).filter(fsPath => Boolean(fsPath)); + } + + // Single-file debugging (e.g. "Launch HTML file") has no workspace folder, so the + // developer-chosen target file's own directory is the only trusted root. + if (this.targetUrl.startsWith('file://')) { + try { + return [path.dirname(vscode.Uri.parse(this.targetUrl).fsPath)]; + } catch { + return []; + } + } + + return []; + } + + private isWithinTrustedRoot(uri: vscode.Uri): boolean { + const target = path.resolve(uri.fsPath); + return this.getTrustedRoots().some(root => { + // path.relative escapes with '..' (or an absolute path) whenever target is outside root, + // and compares case-insensitively on Windows. + const relative = path.relative(path.resolve(root), target); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); + }); + } + private async showCssMirroringWarning() { if (!this.cssWarningActive) { this.cssWarningActive = true; diff --git a/test/devtoolsPanel.test.ts b/test/devtoolsPanel.test.ts index b29b1c87..f051291b 100644 --- a/test/devtoolsPanel.test.ts +++ b/test/devtoolsPanel.test.ts @@ -643,7 +643,7 @@ describe("devtoolsPanel", () => { it("calls openTextDocument for onSocketCssMirrorContent", async () => { const expectedRequest = { - url: "app.js", + url: "g:\\GIT\\testPage\\styles.css", newContent: ".body{color: blue;}" }; @@ -665,6 +665,55 @@ describe("devtoolsPanel", () => { expect(mockVsCode.workspace.openTextDocument).toHaveBeenCalled(); }); + it("does not mirror css to a path outside the workspace", async () => { + // The url originates from the inspected page via a `/*# sourceURL= */` comment. + const expectedRequest = { + url: "c:\\Users\\test\\AppData\\Roaming\\Code\\User\\settings.json", + newContent: ".body{color: blue;}" + }; + + const mockVsCode = jest.requireMock("vscode"); + const mockUtils = { + applyPathMapping: jest.fn().mockImplementation((x) => x), + fetchUri: jest.fn().mockRejectedValue(null), + isHeadlessEnabled: jest.fn(), + getCSSMirrorContentEnabled: jest.fn().mockImplementation(() => true), + }; + jest.doMock("../src/utils", () => mockUtils); + + const dtp = await import("../src/devtoolsPanel"); + const { TextEncoder } = require('util'); + global.TextEncoder = TextEncoder; + dtp.DevToolsPanel.createOrShow(context, mockTelemetry, "", mockRuntimeConfig); + + await hookedEvents.get("cssMirrorContent")!(JSON.stringify(expectedRequest)); + expect(mockVsCode.workspace.openTextDocument).not.toHaveBeenCalled(); + }); + + it("does not mirror css to a path that escapes the workspace via traversal", async () => { + const expectedRequest = { + url: "g:\\GIT\\testPage\\..\\..\\..\\Windows\\system32\\drivers\\etc\\hosts", + newContent: ".body{color: blue;}" + }; + + const mockVsCode = jest.requireMock("vscode"); + const mockUtils = { + applyPathMapping: jest.fn().mockImplementation((x) => x), + fetchUri: jest.fn().mockRejectedValue(null), + isHeadlessEnabled: jest.fn(), + getCSSMirrorContentEnabled: jest.fn().mockImplementation(() => true), + }; + jest.doMock("../src/utils", () => mockUtils); + + const dtp = await import("../src/devtoolsPanel"); + const { TextEncoder } = require('util'); + global.TextEncoder = TextEncoder; + dtp.DevToolsPanel.createOrShow(context, mockTelemetry, "", mockRuntimeConfig); + + await hookedEvents.get("cssMirrorContent")!(JSON.stringify(expectedRequest)); + expect(mockVsCode.workspace.openTextDocument).not.toHaveBeenCalled(); + }); + it("calls getVscodeSettings", async () => { jest.dontMock("../src/common/settingsProvider.ts"); const expectedId = { id: 0 }; diff --git a/test/helpers/helpers.ts b/test/helpers/helpers.ts index f6cb494a..93b1a244 100644 --- a/test/helpers/helpers.ts +++ b/test/helpers/helpers.ts @@ -138,7 +138,10 @@ export function createFakeVSCode() { openTextDocument: jest.fn().mockResolvedValue(null), workspaceFolders: [ { - uri: 'file:///g%3A/GIT/testPage' + uri: { + fsPath: 'g:\\GIT\\testPage', + toString: () => 'file:///g%3A/GIT/testPage', + } } ], fs: {