Skip to content
Merged
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
75 changes: 56 additions & 19 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import fs from 'node:fs/promises';
import fsPromises from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {fileURLToPath, pathToFileURL} from 'node:url';
Expand Down Expand Up @@ -45,7 +46,11 @@ import type {
GeolocationOptions,
ExtensionServiceWorker,
} from './types.js';
import {ensureExtension, getTempFilePath} from './utils/files.js';
import {
ensureExtension,
getTempFilePath,
resolveCanonicalPath,
} from './utils/files.js';
import {getNetworkMultiplierFromString} from './WaitForHelper.js';

interface McpContextOptions {
Expand Down Expand Up @@ -175,27 +180,58 @@ export class McpContext implements Context {
this.#roots = roots;
}

validatePath(filePath?: string): void {
async validatePath(filePath?: string): Promise<void> {
if (filePath === undefined) {
return;
}
const roots = this.roots();
if (roots === undefined) {
return;
}
const absolutePath = path.resolve(filePath);

let canonicalPath: string;

try {
canonicalPath = await resolveCanonicalPath(filePath);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
console.error(
`[MCP Context] Error resolving real path for ${filePath}: ${errMsg}`,
);
throw new Error(
`Access denied: Cannot resolve base path for ${filePath}.`,
);
}

let allowed = false;
for (const root of roots) {
const rootPath = path.resolve(fileURLToPath(root.uri));
if (
absolutePath === rootPath ||
absolutePath.startsWith(rootPath + path.sep)
) {
return;
try {
const rootPathUri = root.uri;
const rootPath = path.resolve(fileURLToPath(rootPathUri));
const canonicalRoot = await fsPromises.realpath(rootPath);

if (
canonicalPath === canonicalRoot ||
canonicalPath.startsWith(canonicalRoot + path.sep)
) {
allowed = true;
break;
}
} catch (rootErr) {
const errMsg =
rootErr instanceof Error ? rootErr.message : String(rootErr);
console.warn(
`[MCP Context] Could not resolve configured root ${root.uri}: ${errMsg}`,
);
// Skip this root if it cannot be resolved.
}
}
throw new Error(
`Access denied: path ${filePath} is not within any of the workspace roots ${JSON.stringify(roots)}.`,
);

if (!allowed) {
throw new Error(
`Access denied: path ${filePath} (canonical: ${canonicalPath}) is not within any of the configured workspace roots.`,
);
}
}

resolveCdpRequestId(page: McpPage, cdpRequestId: string): number | undefined {
Expand Down Expand Up @@ -708,7 +744,7 @@ export class McpContext implements Context {
filename: string,
): Promise<{filepath: string}> {
const filepath = await getTempFilePath(filename);
this.validatePath(filepath);
await this.validatePath(filepath);
try {
await fs.writeFile(filepath, data);
} catch (err) {
Expand All @@ -722,7 +758,7 @@ export class McpContext implements Context {
clientProvidedFilePath: string,
extension: SupportedExtensions,
): Promise<{filename: string}> {
this.validatePath(clientProvidedFilePath);
await this.validatePath(clientProvidedFilePath);
try {
const filePath = ensureExtension(
path.resolve(clientProvidedFilePath),
Expand Down Expand Up @@ -794,7 +830,7 @@ export class McpContext implements Context {
}

async installExtension(extensionPath: string): Promise<string> {
this.validatePath(extensionPath);
await this.validatePath(extensionPath);
const id = await this.browser.installExtension(extensionPath);
return id;
}
Expand Down Expand Up @@ -825,36 +861,37 @@ export class McpContext implements Context {
async getHeapSnapshotAggregates(
filePath: string,
): Promise<Record<string, AggregatedInfoWithId>> {
this.validatePath(filePath);
await this.validatePath(filePath);
return await this.#heapSnapshotManager.getAggregates(filePath);
}

async getHeapSnapshotStats(
filePath: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics> {
this.validatePath(filePath);
await this.validatePath(filePath);
return await this.#heapSnapshotManager.getStats(filePath);
}

async getHeapSnapshotStaticData(
filePath: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null> {
this.validatePath(filePath);
await this.validatePath(filePath);
return await this.#heapSnapshotManager.getStaticData(filePath);
}

async getHeapSnapshotNodesById(
filePath: string,
id: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
this.validatePath(filePath);
await this.validatePath(filePath);
return await this.#heapSnapshotManager.getNodesById(filePath, id);
}

async getHeapSnapshotRetainers(
filePath: string,
nodeId: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
await this.validatePath(filePath);
return await this.#heapSnapshotManager.getRetainers(filePath, nodeId);
}
}
2 changes: 1 addition & 1 deletion src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export type SupportedExtensions =
* Only add methods used by tools/*.
*/
export type Context = Readonly<{
validatePath(filePath?: string): void;
validatePath(filePath?: string): Promise<void>;
isRunningPerformanceTrace(): boolean;
setIsRunningPerformanceTrace(x: boolean): void;
isCruxEnabled(): boolean;
Expand Down
2 changes: 1 addition & 1 deletion src/tools/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ export const uploadFile = definePageTool({
blockedByDialog: true,
handler: async (request, response, context) => {
const {uid, filePath} = request.params;
context.validatePath(filePath);
await context.validatePath(filePath);
const handle = (await request.page.getElementByUid(
uid,
)) as ElementHandle<HTMLInputElement>;
Expand Down
2 changes: 1 addition & 1 deletion src/tools/lighthouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const lighthouseAudit = definePageTool({
outputDirPath,
} = request.params;

context.validatePath(outputDirPath);
await context.validatePath(outputDirPath);

const flags: Flags = {
onlyCategories: categories,
Expand Down
10 changes: 5 additions & 5 deletions src/tools/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const takeHeapSnapshot = definePageTool({
blockedByDialog: true,
handler: async (request, response, context) => {
const page = request.page;
context.validatePath(request.params.filePath);
await context.validatePath(request.params.filePath);

await page.pptrPage.captureHeapSnapshot({
path: ensureExtension(request.params.filePath, '.heapsnapshot'),
Expand All @@ -51,7 +51,7 @@ export const getHeapSnapshotSummary = defineTool({
},
blockedByDialog: false,
handler: async (request, response, context) => {
context.validatePath(request.params.filePath);
await context.validatePath(request.params.filePath);
const stats = await context.getHeapSnapshotStats(request.params.filePath);
const staticData = await context.getHeapSnapshotStaticData(
request.params.filePath,
Expand Down Expand Up @@ -83,7 +83,7 @@ export const getHeapSnapshotDetails = defineTool({
},
blockedByDialog: false,
handler: async (request, response, context) => {
context.validatePath(request.params.filePath);
await context.validatePath(request.params.filePath);
const aggregates = await context.getHeapSnapshotAggregates(
request.params.filePath,
);
Expand Down Expand Up @@ -112,7 +112,7 @@ export const getHeapSnapshotClassNodes = defineTool({
},
blockedByDialog: false,
handler: async (request, response, context) => {
context.validatePath(request.params.filePath);
await context.validatePath(request.params.filePath);
const nodes = await context.getHeapSnapshotNodesById(
request.params.filePath,
request.params.id,
Expand Down Expand Up @@ -142,7 +142,7 @@ export const getHeapSnapshotRetainers = defineTool({
pageSize: zod.number().optional().describe('The page size for pagination.'),
},
handler: async (request, response, context) => {
context.validatePath(request.params.filePath);
await context.validatePath(request.params.filePath);

const retainers = await context.getHeapSnapshotRetainers(
request.params.filePath,
Expand Down
4 changes: 2 additions & 2 deletions src/tools/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ export const getNetworkRequest = definePageTool({
},
blockedByDialog: true,
handler: async (request, response, context) => {
context.validatePath(request.params.requestFilePath);
context.validatePath(request.params.responseFilePath);
await context.validatePath(request.params.requestFilePath);
await context.validatePath(request.params.responseFilePath);
if (request.params.reqid) {
response.attachNetworkRequest(request.params.reqid, {
requestFilePath: request.params.requestFilePath,
Expand Down
4 changes: 2 additions & 2 deletions src/tools/performance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const startTrace = definePageTool({
},
blockedByDialog: true,
handler: async (request, response, context) => {
context.validatePath(request.params.filePath);
await context.validatePath(request.params.filePath);
if (context.isRunningPerformanceTrace()) {
response.appendResponseLine(
'Error: a performance trace is already running. Use performance_stop_trace to stop it. Only one trace can be running at any given time.',
Expand Down Expand Up @@ -128,7 +128,7 @@ export const stopTrace = definePageTool({
},
blockedByDialog: true,
handler: async (request, response, context) => {
context.validatePath(request.params.filePath);
await context.validatePath(request.params.filePath);
if (!context.isRunningPerformanceTrace()) {
return;
}
Expand Down
2 changes: 1 addition & 1 deletion src/tools/screencast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const startScreencast = definePageTool(args => ({
},
blockedByDialog: false,
handler: async (request, response, context) => {
context.validatePath(request.params.filePath);
await context.validatePath(request.params.filePath);
if (context.getScreenRecorder() !== null) {
response.appendResponseLine(
'Error: a screencast recording is already in progress. Use screencast_stop to stop it before starting a new one.',
Expand Down
2 changes: 1 addition & 1 deletion src/tools/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const screenshot = definePageTool({
},
blockedByDialog: true,
handler: async (request, response, context) => {
context.validatePath(request.params.filePath);
await context.validatePath(request.params.filePath);
if (request.params.uid && request.params.fullPage) {
throw new Error('Providing both "uid" and "fullPage" is not allowed.');
}
Expand Down
2 changes: 1 addition & 1 deletion src/tools/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Example with arguments: \`(el) => {
filePath,
} = request.params;

context.validatePath(filePath);
await context.validatePath(filePath);

if (cliArgs?.categoryExtensions && serviceWorkerId) {
if (uidArgs && uidArgs.length > 0) {
Expand Down
2 changes: 1 addition & 1 deletion src/tools/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ in the DevTools Elements panel (if any).`,
},
blockedByDialog: true,
handler: async (request, response, context) => {
context.validatePath(request.params.filePath);
await context.validatePath(request.params.filePath);
response.includeSnapshot({
verbose: request.params.verbose ?? false,
filePath: request.params.filePath,
Expand Down
48 changes: 48 additions & 0 deletions src/utils/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,51 @@ export function ensureExtension(
const ext = path.extname(filepath);
return filepath.slice(0, filepath.length - ext.length) + extension;
}

export async function resolveCanonicalPath(filePath: string): Promise<string> {
const absolutePath = path.resolve(filePath);
try {
// Get the true canonical path, resolving all symlinks.
return await fs.realpath(absolutePath);
} catch (err) {
if (
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'ENOENT'
) {
// Find the nearest existing ancestor directory on the filesystem.
let current = absolutePath;
const missingSegments: string[] = [];
while (true) {
const parent = path.dirname(current);
if (parent === current) {
// Reached root directory but still couldn't resolve anything.
throw err;
}
try {
const canonicalParent = await fs.realpath(parent);
return path.join(
canonicalParent,
path.basename(current),
...missingSegments,
);
} catch (parentErr) {
if (
parentErr &&
typeof parentErr === 'object' &&
'code' in parentErr &&
parentErr.code === 'ENOENT'
) {
missingSegments.unshift(path.basename(current));
current = parent;
} else {
throw parentErr;
}
}
}
} else {
throw err;
}
}
}
Loading
Loading