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:
Alex Rudenko authored and GitHub committed 2026-10-22 12:43:42 +00:00
1 parent db442f9636
commit 7d5f7dec09
4 files changed
+119 -2

No files matched your search

+2 -2
View File
@@ -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;
+30
View File
@@ -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;
+47
View File
@@ -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;
+40
View File
@@ -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);