mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 05:02:31 +00:00
fix: treat 0.0.0.0 and [::] browser endpoints as local (#3321)
`isLocalBrowser()` in `ToolHandler` uses `isLocalhost()` to decide if a connected browser runs on the local machine. `isLocalhost()` does not accept the unspecified addresses `0.0.0.0` and `[::]`. A connection to these addresses reaches the local machine. Thus, `--browserUrl http://0.0.0.0:9222` caused the tool handler to classify a local browser as remote. ## Changes - `src/utils/url.ts`: add `isLocalBrowserEndpoint()`. It accepts all `isLocalhost()` hosts and the unspecified addresses `0.0.0.0`, `[::]` and `[::ffff:0:0]`. `URL` normalizes `0`, `0x0`, `0.0`, `0.0.0.0.` and `[::0]` to these forms. - `src/ToolHandler.ts`: `isLocalBrowser()` uses `isLocalBrowserEndpoint()`. - `isLocalhost()` does not change. The telemetry `is_localhost` value for page URLs does not change. ## Behavior changes For a browser that is connected through a `0.0.0.0` or `[::]` endpoint, these tools now run the local file path checks for local browsers: - `upload_file` (`filePaths`) - `install_pwa` (`installUrlOrBundleUrl`) - `install_extension` (`path`) Before this change, these tools skipped the checks for such endpoints. ## Tests - `tests/utils/url.test.ts`: unit tests for `isLocalBrowserEndpoint()`. - `tests/ToolHandler.test.ts`: a local-only `verifyFilesSchema` runs `validatePath` for a `ws://0.0.0.0` endpoint. This test fails on `main`.
This commit is contained in:
1 parent
db442f9636
commit
7d5f7dec09
4 files changed
+119
-2
No files matched your search
+2
-2
@@ -37,7 +37,7 @@ import {logger} from './utils/logger.js';
|
||||
import type {TaskQueue} from './utils/TaskRunner.js';
|
||||
import {fileURLToPath, pathToFileURL} from 'node:url';
|
||||
import {isDeepStrictEqual} from 'node:util';
|
||||
import {isLocalhost} from './utils/url.js';
|
||||
import {isLocalBrowserEndpoint} from './utils/url.js';
|
||||
|
||||
/**
|
||||
* Upper bound on how long a tool call may wait to acquire the browser
|
||||
@@ -136,7 +136,7 @@ function isLocalBrowser(browser: Browser): boolean {
|
||||
return true;
|
||||
}
|
||||
const wsEndpoint = browser.wsEndpoint();
|
||||
if (wsEndpoint && isLocalhost(wsEndpoint)) {
|
||||
if (wsEndpoint && isLocalBrowserEndpoint(wsEndpoint)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -60,6 +60,36 @@ export function isLocalhost(url?: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// URL serializes `0`, `0x0` and `0.0` as 0.0.0.0, `[::0]` as [::], and
|
||||
// [::ffff:0.0.0.0] as [::ffff:0:0].
|
||||
const UNSPECIFIED_HOSTNAMES = new Set(['0.0.0.0', '[::]', '[::ffff:0:0]']);
|
||||
|
||||
/**
|
||||
* Determines whether a browser endpoint URL points to the local machine.
|
||||
*
|
||||
* In addition to {@link isLocalhost}, this accepts the unspecified addresses
|
||||
* 0.0.0.0 and [::]. A connection to an unspecified address reaches the local
|
||||
* machine.
|
||||
*
|
||||
* @param url The browser endpoint URL (for example, a WebSocket endpoint).
|
||||
* @returns true if the endpoint is on the local machine.
|
||||
*/
|
||||
export function isLocalBrowserEndpoint(url: string): boolean {
|
||||
if (isLocalhost(url)) {
|
||||
return true;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
|
||||
return false;
|
||||
}
|
||||
return UNSPECIFIED_HOSTNAMES.has(parsed.hostname);
|
||||
}
|
||||
|
||||
export interface ValidateUrlOptions {
|
||||
javascriptEvaluation: boolean | undefined;
|
||||
categoryExtensions: boolean | undefined;
|
||||
|
||||
@@ -1330,6 +1330,53 @@ describe('ToolHandler', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('validates local-only verifyFilesSchema when browser is connected to a 0.0.0.0 wsEndpoint', async () => {
|
||||
const handler = sinon.stub().resolves();
|
||||
const tool: ToolDefinition = {
|
||||
name: 'upload_tool',
|
||||
description: 'A tool with local-only file verification',
|
||||
annotations: {
|
||||
category: ToolCategory.INPUT,
|
||||
readOnlyHint: false,
|
||||
},
|
||||
schema: {
|
||||
filePaths: zod.array(zod.string()),
|
||||
},
|
||||
blockedByDialog: false,
|
||||
concurrency: 'exclusive',
|
||||
verifyFilesSchema: {
|
||||
filePaths: {
|
||||
local: true,
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
handler,
|
||||
};
|
||||
|
||||
const mockContext = createMockMcpContext();
|
||||
mockContext.ensureBrowser.resolves(
|
||||
getMockBrowser({
|
||||
wsEndpoint: 'ws://0.0.0.0:9222/devtools/browser/test',
|
||||
}),
|
||||
);
|
||||
mockContext.validatePath.rejects(new Error('Access denied'));
|
||||
|
||||
const toolHandler = new ToolHandler(
|
||||
tool,
|
||||
parseConfig(),
|
||||
async () => mockContext,
|
||||
sinon.spy(),
|
||||
);
|
||||
|
||||
const result = await toolHandler.handle({
|
||||
filePaths: ['/etc/passwd'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.isError, true);
|
||||
sinon.assert.calledOnceWithExactly(mockContext.validatePath, '/etc/passwd');
|
||||
sinon.assert.notCalled(handler);
|
||||
});
|
||||
|
||||
it('skips local-only verifyFilesSchema when browser is remote', async () => {
|
||||
let handlerCalled = false;
|
||||
let receivedParams: Record<string, unknown> | undefined;
|
||||
|
||||
@@ -9,11 +9,51 @@ import {describe, it} from 'node:test';
|
||||
|
||||
import {
|
||||
isAllowedUrl,
|
||||
isLocalBrowserEndpoint,
|
||||
isLocalhost,
|
||||
validateUrl,
|
||||
validateUrlPatterns,
|
||||
} from '../../src/utils/url.js';
|
||||
|
||||
describe('isLocalBrowserEndpoint', () => {
|
||||
it('returns true for loopback endpoints', () => {
|
||||
assert.strictEqual(
|
||||
isLocalBrowserEndpoint('ws://127.0.0.1:9222/devtools/browser/id'),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(isLocalBrowserEndpoint('ws://localhost:9222'), true);
|
||||
assert.strictEqual(isLocalBrowserEndpoint('ws://[::1]:9222'), true);
|
||||
});
|
||||
|
||||
it('returns true for unspecified address endpoints', () => {
|
||||
assert.strictEqual(
|
||||
isLocalBrowserEndpoint('ws://0.0.0.0:9222/devtools/browser/id'),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(isLocalBrowserEndpoint('http://0:9222'), true);
|
||||
assert.strictEqual(isLocalBrowserEndpoint('ws://0x0:9222'), true);
|
||||
assert.strictEqual(isLocalBrowserEndpoint('ws://0.0.0.0.:9222'), true);
|
||||
assert.strictEqual(isLocalBrowserEndpoint('ws://[::]:9222'), true);
|
||||
assert.strictEqual(isLocalBrowserEndpoint('ws://[::0]:9222'), true);
|
||||
assert.strictEqual(
|
||||
isLocalBrowserEndpoint('ws://[::ffff:0.0.0.0]:9222'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false for remote and invalid endpoints', () => {
|
||||
assert.strictEqual(
|
||||
isLocalBrowserEndpoint('ws://remote-host.com:9222'),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(isLocalBrowserEndpoint('ws://0.0.0.1:9222'), false);
|
||||
assert.strictEqual(isLocalBrowserEndpoint('ws://[::2]:9222'), false);
|
||||
assert.strictEqual(isLocalBrowserEndpoint('file://0.0.0.0/x'), false);
|
||||
assert.strictEqual(isLocalBrowserEndpoint(''), false);
|
||||
assert.strictEqual(isLocalBrowserEndpoint('not a url'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLocalhost', () => {
|
||||
it('should return true for valid localhost and loopback URLs', () => {
|
||||
assert.strictEqual(isLocalhost('http://localhost'), true);
|
||||
|
||||
Reference in new issue
Block a user