Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/devtoolsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
51 changes: 50 additions & 1 deletion test/devtoolsPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;}"
};

Expand All @@ -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 };
Expand Down
5 changes: 4 additions & 1 deletion test/helpers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading