项目文件夹

文件
herdiyanitdev 6e56c028cf feat: support --allow-unrestricted-paths configuration (#2296)
## Summary

validatePath() in McpContext returned immediately, with no restriction
at all, whenever roots() returned undefined. roots() only returns
undefined when the connecting MCP client never negotiates the optional
roots capability during initialize, which any minimal client can trigger
simply by omitting it from its declared capabilities.

Since roots() already always appends the OS temp directory to whatever
explicit roots are configured, this change makes it return that same
default (temp directory only) instead of undefined when no roots have
been set. This removes the early return in validatePath() entirely, so
path validation now runs unconditionally rather than being conditional
on whether the connecting client happened to negotiate a capability it
was never required to declare per the MCP spec.


Any filePath-accepting tool (take_screenshot, saveFile, and the
performance/Lighthouse export tools that route through the same check)
had its only path-traversal guard silently disabled for the lifetime of
a connection whenever the client omitted the optional roots capability.
Since this server is designed to let an LLM drive a browser, and browsed
page content is not trusted input, this meant a client that simply
doesn't implement roots (a plausible, non-adversarial default for
lightweight or custom MCP clients) removed the only boundary preventing
the connected agent from writing to any path the process can reach.


Added a test that exercises the actual default state of roots (never
calling setRoots()) directly, since the existing tests always call
setRoots(), even with an empty array, before validating. Verified
locally with a minimal MCP client that declares no capabilities: before
this change, take_screenshot with a filePath outside any root wrote a
real file to an arbitrary path with no error; after this change, the
same call is rejected with the existing Access denied error. Also
verified that a client that does declare roots is unaffected, and that
writes to the OS temp directory continue to succeed with no roots
negotiated, matching prior behavior for that path.
2026-07-08 16:49:31 +00:00

431 行
12 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {spawn} from 'node:child_process';
import path from 'node:path';
import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js';
import logger from 'debug';
import type {Browser} from 'puppeteer';
import puppeteer, {Locator} from 'puppeteer';
import type {
Frame,
HTTPRequest,
HTTPResponse,
LaunchOptions,
Page,
Target,
} from 'puppeteer-core';
import sinon from 'sinon';
import type {ParsedArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js';
import {McpContext} from '../src/McpContext.js';
import {McpResponse} from '../src/McpResponse.js';
import {TextSnapshot} from '../src/TextSnapshot.js';
import {DevTools} from '../src/third_party/index.js';
import {stableIdSymbol} from '../src/utils/id.js';
export function assertNoServiceWorkerReported(targets: Target[], id: string) {
const target = targets.find(target => {
return target.url().includes(id) && target.type() === 'service_worker';
});
assert(target === undefined);
}
export function getTextContent(
content: CallToolResult['content'][number],
): string {
if (content.type === 'text') {
return content.text;
}
throw new Error(`Expected text content but got ${content.type}`);
}
export function getImageContent(content: CallToolResult['content'][number]): {
data: string;
mimeType: string;
} {
if (content.type === 'image') {
return {data: content.data, mimeType: content.mimeType};
}
throw new Error(`Expected image content but got ${content.type}`);
}
export function extractExtensionId(response: McpResponse) {
const responseLine = response.responseLines[0];
assert.ok(responseLine, 'Response should not be empty');
const match = responseLine.match(/Extension installed\. Id: (.+)/);
const extensionId = match ? match[1] : null;
assert.ok(extensionId, 'Response should contain a valid key');
return extensionId;
}
const browsers = new Map<string, Browser>();
let context: McpContext | undefined;
export async function withBrowser(
cb: (browser: Browser, page: Page) => Promise<void>,
options: {
debug?: boolean;
autoOpenDevTools?: boolean;
executablePath?: string;
args?: string[];
blockedUrlPattern?: string[];
allowedUrlPattern?: string[];
} = {},
) {
const launchOptions: LaunchOptions = {
executablePath:
options.executablePath ?? process.env.PUPPETEER_EXECUTABLE_PATH,
headless: !options.debug,
defaultViewport: null,
devtools: options.autoOpenDevTools ?? false,
pipe: true,
handleDevToolsAsPage: true,
args: [...(options.args || []), '--screen-info={3840x2160}'],
enableExtensions: true,
blocklist: options.blockedUrlPattern,
allowlist: options.allowedUrlPattern,
};
const key = JSON.stringify(launchOptions);
let browser = browsers.get(key);
if (!browser) {
browser = await puppeteer.launch(launchOptions);
browsers.set(key, browser);
}
const newPage = await browser.newPage();
// Close other pages.
await Promise.all(
(await browser.pages()).map(async page => {
if (page !== newPage) {
await page.close();
}
}),
);
await cb(browser, newPage);
}
export async function withMcpContext(
cb: (response: McpResponse, context: McpContext) => Promise<void>,
options: {
debug?: boolean;
autoOpenDevTools?: boolean;
performanceCrux?: boolean;
executablePath?: string;
args?: string[];
blockedUrlPattern?: string[];
allowedUrlPattern?: string[];
allowUnrestrictedPaths?: boolean;
} = {},
args: Partial<ParsedArguments> = {},
) {
await withBrowser(async browser => {
TextSnapshot.resetCounter();
const response = new McpResponse(args as ParsedArguments);
if (context) {
context.dispose();
}
context = await McpContext.from(
browser,
logger('test'),
{
experimentalDevToolsDebugging: false,
performanceCrux: options.performanceCrux ?? true,
allowList: options.allowedUrlPattern,
blocklist: options.blockedUrlPattern,
allowUnrestrictedPaths: options.allowUnrestrictedPaths ?? false,
},
Locator,
);
response.setPage(context.getSelectedMcpPage());
await cb(response, context);
}, options);
}
export function getMockRequest(
options: {
url?: string;
method?: string;
response?: HTTPResponse;
failure?: HTTPRequest['failure'];
resourceType?: string;
hasPostData?: boolean;
postData?: string;
fetchPostData?: Promise<string>;
stableId?: number;
navigationRequest?: boolean;
frame?: Frame;
redirectChain?: HTTPRequest[];
headers?: Record<string, string>;
} = {},
): HTTPRequest {
return {
url() {
return options.url ?? 'http://example.com';
},
method() {
return options.method ?? 'GET';
},
fetchPostData() {
return options.fetchPostData ?? Promise.reject();
},
hasPostData() {
return options.hasPostData ?? false;
},
postData() {
return options.postData;
},
response() {
return options.response ?? null;
},
failure() {
return options.failure?.() ?? null;
},
resourceType() {
return options.resourceType ?? 'document';
},
headers(): Record<string, string> {
return (
options.headers ?? {
'content-size': '10',
}
);
},
redirectChain(): HTTPRequest[] {
// Puppeteer returns a fresh copy on every call (HTTPRequest returns
// `this._redirectChain.slice()`); mirror that so formatters can't share
// and accidentally mutate the same array across calls.
return [...(options.redirectChain ?? [])];
},
isNavigationRequest() {
return options.navigationRequest ?? false;
},
frame() {
return options.frame ?? ({} as Frame);
},
[stableIdSymbol]: options.stableId ?? 1,
} as unknown as HTTPRequest;
}
export function getMockResponse(
options: {
status?: number;
headers?: Record<string, string>;
} = {},
): HTTPResponse {
return {
status() {
return options.status ?? 200;
},
headers(): Record<string, string> {
return options.headers ?? {};
},
} as unknown as HTTPResponse;
}
export function html(
strings: TemplateStringsArray,
...values: unknown[]
): string {
const bodyContent = strings.reduce((acc, str, i) => {
return acc + str + (values[i] || '');
}, '');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My test page</title>
</head>
<body>
${bodyContent}
</body>
</html>`;
}
export function stabilizeStructuredContent(content: unknown): unknown {
if (typeof content === 'string') {
return stabilizeResponseOutput(content);
}
if (Array.isArray(content)) {
return content.map(item => stabilizeStructuredContent(item));
}
if (typeof content === 'object' && content !== null) {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(content)) {
if (key === 'snapshotFilePath' && typeof value === 'string') {
result[key] = '<file>';
} else {
result[key] = stabilizeStructuredContent(value);
}
}
return result;
}
return content;
}
export function stabilizeResponseOutput(text: unknown) {
if (typeof text !== 'string') {
throw new Error('Input must be string');
}
let output = text;
const dateRegEx = /.{3}, \d{2} .{3} \d{4} \d{2}:\d{2}:\d{2} [A-Z]{3}/g;
output = output.replaceAll(dateRegEx, '<long date>');
const localhostRegEx = /localhost:\d{5}/g;
output = output.replaceAll(localhostRegEx, 'localhost:<port>');
const userAgentRegEx = /user-agent:.*\n/g;
output = output.replaceAll(userAgentRegEx, 'user-agent:<user-agent>\n');
const chUaRegEx = /sec-ch-ua:"Chromium";v="\d{3}"/g;
output = output.replaceAll(chUaRegEx, 'sec-ch-ua:"Chromium";v="<version>"');
// sec-ch-ua-platform:"Linux"
const chUaPlatformRegEx = /sec-ch-ua-platform:"[a-zA-Z]*"/g;
output = output.replaceAll(chUaPlatformRegEx, 'sec-ch-ua-platform:"<os>"');
const savedSnapshot = /Saved snapshot to (.*)/g;
output = output.replaceAll(savedSnapshot, 'Saved snapshot to <file>');
const acceptLanguageRegEx = /accept-language:.*\n/g;
output = output.replaceAll(acceptLanguageRegEx, 'accept-language:<lang>\n');
// Stabilize URL-encoded file paths
const fileUriRegEx = /file%3A%2F%2F%2F[^)\n]+/g;
output = output.replaceAll(fileUriRegEx, '<file-path>');
return output;
}
export function getMockAggregatedIssue(): sinon.SinonStubbedInstance<DevTools.AggregatedIssue> {
const mockAggregatedIssue = sinon.createStubInstance(
DevTools.AggregatedIssue,
);
mockAggregatedIssue.getAllIssues.returns([]);
return mockAggregatedIssue;
}
export function mockListener() {
const listeners: Record<string, Array<(data: unknown) => void>> = {};
return {
on(eventName: string, listener: (data: unknown) => void) {
if (listeners[eventName]) {
listeners[eventName].push(listener);
} else {
listeners[eventName] = [listener];
}
},
off(_eventName: string, _listener: (data: unknown) => void) {
// no-op
},
emit(eventName: string, data: unknown) {
for (const listener of listeners[eventName] ?? []) {
listener(data);
}
},
};
}
export function getMockPage(): Page {
const mainFrame = {} as Frame;
const cdpSession = {
...mockListener(),
send: () => {
// no-op
},
target: () => ({_targetId: '<mock target ID>'}),
};
return {
mainFrame() {
return mainFrame;
},
...mockListener(),
// @ts-expect-error internal API.
_client() {
return cdpSession;
},
} satisfies Page;
}
export function getMockBrowser(): Browser {
const pages = [getMockPage()];
return {
pages() {
return Promise.resolve(pages);
},
...mockListener(),
} as Browser;
}
export const CLI_PATH = path.resolve('build/src/bin/chrome-devtools.js');
export async function runCli(
args: string[],
sessionId?: string,
): Promise<{status: number | null; stdout: string; stderr: string}> {
return new Promise((resolve, reject) => {
const finalArgs = [...args];
if (sessionId) {
finalArgs.push('--sessionId', sessionId);
}
const child = spawn('node', [CLI_PATH, ...finalArgs], {
env: process.env,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => {
stdout += chunk;
process.stdout.write(chunk);
});
child.stderr.on('data', chunk => {
stderr += chunk;
process.stderr.write(chunk);
});
child.on('close', status => resolve({status, stdout, stderr}));
child.on('error', reject);
});
}
export async function assertDaemonIsNotRunning(sessionId?: string) {
const result = await runCli(['status'], sessionId);
assert.strictEqual(
result.stdout,
'chrome-devtools-mcp daemon is not running.\n',
);
}
export async function assertDaemonIsRunning(sessionId?: string) {
const result = await runCli(['status'], sessionId);
assert.ok(
result.stdout.startsWith('chrome-devtools-mcp daemon is running.\n'),
'chrome-devtools-mcp daemon is not running',
);
}
export async function waitExecutionFor(
func: () => Promise<void>,
timeout: number,
) {
const start = Date.now();
while (Date.now() - start < 10000) {
try {
await func();
return;
} catch {
await new Promise(resolve => setTimeout(resolve, 100)); // wait and retry
}
}
throw new Error(`Timeout of ${timeout} reached.`);
}