mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-14 19:45:30 +08:00
fix: filter out Chrome webui targets by default (#2648)
This commit is contained in:
@@ -18,3 +18,4 @@ This repository contains an MCP server and CLI for Chrome DevTools.
|
||||
- Do not use `// @ts-nocheck` comments.
|
||||
- Do not use `// @ts-expect-error` comments.
|
||||
- Prefer `for..of` instead of `forEach`.
|
||||
- Never type-check types that are already type safe (e.g. redundant `typeof` checks on statically typed variables).
|
||||
|
||||
+48
-26
@@ -50,6 +50,7 @@ import type {TraceResult} from './processors/PerformanceTrace.js';
|
||||
import type {Logger} from './types.js';
|
||||
import type {ExtensionServiceWorker} from './types.js';
|
||||
import {getTempFilePath, resolveCanonicalPath} from './utils/files.js';
|
||||
import {isAllowedUrl} from './utils/url.js';
|
||||
interface McpContextOptions {
|
||||
// Whether the DevTools windows are exposed as pages for debugging of DevTools.
|
||||
experimentalDevToolsDebugging: boolean;
|
||||
@@ -70,6 +71,8 @@ interface McpContextOptions {
|
||||
reconnected?: boolean;
|
||||
// Custom navigation timeout in milliseconds to override default.
|
||||
navigationTimeout?: number;
|
||||
// Whether extension tools and targets are enabled.
|
||||
categoryExtensions?: boolean;
|
||||
}
|
||||
|
||||
// Page ids are handed out from a process-wide counter so they stay unique
|
||||
@@ -164,6 +167,14 @@ export class McpContext implements Context {
|
||||
|
||||
#onTargetCreated = async (target: Target) => {
|
||||
try {
|
||||
const url = target.url();
|
||||
if (
|
||||
!isAllowedUrl(url, {
|
||||
categoryExtensions: this.#options.categoryExtensions,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const page = await target.page();
|
||||
if (!page) {
|
||||
return;
|
||||
@@ -604,36 +615,47 @@ export class McpContext implements Context {
|
||||
const allPages = (
|
||||
await this.browser.pages(this.#options.experimentalIncludeAllPages)
|
||||
).filter(page => {
|
||||
return (
|
||||
this.#options.experimentalDevToolsDebugging ||
|
||||
!page.url().startsWith('devtools://')
|
||||
);
|
||||
if (
|
||||
!this.#options.experimentalDevToolsDebugging &&
|
||||
page.url().startsWith('devtools://')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return isAllowedUrl(page.url(), {
|
||||
categoryExtensions: this.#options.categoryExtensions,
|
||||
});
|
||||
});
|
||||
|
||||
const allTargets = this.browser.targets();
|
||||
const extensionTargets = allTargets.filter(target => {
|
||||
return (
|
||||
target.url().startsWith('chrome-extension://') &&
|
||||
target.type() === 'page'
|
||||
);
|
||||
});
|
||||
if (this.#options.categoryExtensions) {
|
||||
const allTargets = this.browser.targets();
|
||||
const extensionTargets = allTargets.filter(target => {
|
||||
return (
|
||||
target.url().startsWith('chrome-extension://') &&
|
||||
target.type() === 'page'
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.allSettled(
|
||||
extensionTargets.map(async target => {
|
||||
try {
|
||||
let page = await target.page();
|
||||
if (!page) {
|
||||
page = await target.asPage();
|
||||
await Promise.allSettled(
|
||||
extensionTargets.map(async target => {
|
||||
try {
|
||||
let page = await target.page();
|
||||
if (!page) {
|
||||
page = await target.asPage();
|
||||
}
|
||||
this.#extensionPages.set(target, page);
|
||||
if (
|
||||
page &&
|
||||
isAllowedUrl(page.url(), {categoryExtensions: true}) &&
|
||||
!allPages.includes(page)
|
||||
) {
|
||||
allPages.push(page);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger?.('Failed to get page for extension target', e);
|
||||
}
|
||||
this.#extensionPages.set(target, page);
|
||||
if (page && !allPages.includes(page)) {
|
||||
allPages.push(page);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger?.('Failed to get page for extension target', e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return allPages;
|
||||
}
|
||||
|
||||
+6
-19
@@ -13,34 +13,21 @@ import type {
|
||||
Browser,
|
||||
ChromeReleaseChannel,
|
||||
LaunchOptions,
|
||||
Target,
|
||||
} from './third_party/index.js';
|
||||
import {puppeteer} from './third_party/index.js';
|
||||
import {logger, puppeteerLogger} from './utils/logger.js';
|
||||
import {isAllowedUrl} from './utils/url.js';
|
||||
|
||||
let browser: Browser | undefined;
|
||||
let browserMode: 'launched' | 'connected' | undefined;
|
||||
|
||||
function makeTargetFilter(enableExtensions = false) {
|
||||
const ignoredPrefixes = new Set(['chrome://', 'chrome-untrusted://']);
|
||||
if (!enableExtensions) {
|
||||
ignoredPrefixes.add('chrome-extension://');
|
||||
}
|
||||
|
||||
return function targetFilter(target: Target): boolean {
|
||||
if (target.url() === 'chrome://newtab/') {
|
||||
export function makeTargetFilter(enableExtensions = false) {
|
||||
return function targetFilter(target: {url(): string}): boolean {
|
||||
const url = target.url();
|
||||
if (!url) {
|
||||
return true;
|
||||
}
|
||||
// Could be the only page opened in the browser.
|
||||
if (target.url().startsWith('chrome://inspect')) {
|
||||
return true;
|
||||
}
|
||||
for (const prefix of ignoredPrefixes) {
|
||||
if (target.url().startsWith(prefix)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
return isAllowedUrl(url, {categoryExtensions: enableExtensions});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,7 @@ export class McpServer {
|
||||
allowUnrestrictedPaths: this.#serverArgs.allowUnrestrictedPaths,
|
||||
// Surfaces a one-time note in the next response after a reconnect.
|
||||
reconnected: this.#context !== undefined,
|
||||
categoryExtensions: this.#serverArgs.categoryExtensions,
|
||||
});
|
||||
this.#context.setRoots(this.#combinedRoots());
|
||||
if (this.#lastClientRoots === undefined) {
|
||||
|
||||
+8
-2
@@ -126,7 +126,10 @@ export const newPage = defineTool(args => {
|
||||
blockedByDialog: false,
|
||||
verifyFilesSchema: {},
|
||||
handler: async (request, response, context) => {
|
||||
validateUrl(request.params.url, args?.javascriptEvaluation);
|
||||
validateUrl(request.params.url, {
|
||||
javascriptEvaluation: args?.javascriptEvaluation,
|
||||
categoryExtensions: args?.categoryExtensions,
|
||||
});
|
||||
|
||||
const page = await context.newPage(
|
||||
request.params.background,
|
||||
@@ -203,7 +206,10 @@ export const navigatePage = definePageTool(args => {
|
||||
}
|
||||
|
||||
if (request.params.url) {
|
||||
validateUrl(request.params.url, args?.javascriptEvaluation);
|
||||
validateUrl(request.params.url, {
|
||||
javascriptEvaluation: args?.javascriptEvaluation,
|
||||
categoryExtensions: args?.categoryExtensions,
|
||||
});
|
||||
}
|
||||
|
||||
let initScriptId: string | undefined;
|
||||
|
||||
@@ -49,7 +49,10 @@ export const navigate = definePageTool(args => {
|
||||
blockedByDialog: false,
|
||||
verifyFilesSchema: {},
|
||||
handler: async (request, response) => {
|
||||
validateUrl(request.params.url, args?.javascriptEvaluation);
|
||||
validateUrl(request.params.url, {
|
||||
javascriptEvaluation: args?.javascriptEvaluation,
|
||||
categoryExtensions: args?.categoryExtensions,
|
||||
});
|
||||
|
||||
const page = request.page;
|
||||
|
||||
|
||||
+77
-4
@@ -49,17 +49,81 @@ export function isLocalhost(url?: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface ValidateUrlOptions {
|
||||
javascriptEvaluation: boolean | undefined;
|
||||
categoryExtensions: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface IsAllowedUrlOptions {
|
||||
categoryExtensions: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a URL is allowed for navigation and page tracking.
|
||||
*
|
||||
* Disallowed schemes:
|
||||
* - chrome: (except chrome://newtab/ and chrome://inspect*)
|
||||
* - chrome-untrusted:
|
||||
* - chrome-extension: (unless categoryExtensions is true)
|
||||
*
|
||||
* @param url The URL string to test.
|
||||
* @param options Configuration specifying whether extensions are enabled.
|
||||
* @returns true if the URL is allowed.
|
||||
*/
|
||||
export function isAllowedUrl(
|
||||
url: string,
|
||||
options: IsAllowedUrlOptions,
|
||||
): boolean {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed.protocol === 'chrome:') {
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const path = parsed.pathname.toLowerCase();
|
||||
if (host === 'newtab' || host === 'new-tab-page' || host === 'inspect') {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
host === '' &&
|
||||
(path === 'newtab' ||
|
||||
path === 'new-tab-page' ||
|
||||
path.startsWith('inspect'))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
parsed.protocol === 'chrome:' ||
|
||||
parsed.protocol === 'chrome-untrusted:'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!options.categoryExtensions && parsed.protocol === 'chrome-extension:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const DISALLOWED_PROTOCOLS = new Set(['javascript:', 'data:', 'vbscript:']);
|
||||
|
||||
/**
|
||||
* Validates a URL string by parsing it with `new URL` and checking for disallowed protocols.
|
||||
* Validates a URL string by parsing it with `new URL` and checking for disallowed protocols and restricted schemes.
|
||||
*
|
||||
* @param url The URL string to validate.
|
||||
* @param javascriptEvaluation Whether JavaScript evaluation is enabled.
|
||||
* @param options Options object containing javascriptEvaluation and categoryExtensions.
|
||||
* @returns The parsed URL.
|
||||
* @throws Error if the URL does not parse with `new URL`, or if JavaScript evaluation is disabled and a disallowed URL is passed.
|
||||
* @throws Error if the URL does not parse with `new URL`, or if JavaScript evaluation is disabled and a disallowed URL is passed,
|
||||
* or if navigating to a restricted scheme.
|
||||
*/
|
||||
export function validateUrl(url: string, javascriptEvaluation?: boolean): URL {
|
||||
export function validateUrl(url: string, options: ValidateUrlOptions): URL {
|
||||
const {javascriptEvaluation, categoryExtensions} = options;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
@@ -78,5 +142,14 @@ export function validateUrl(url: string, javascriptEvaluation?: boolean): URL {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAllowedUrl(url, {categoryExtensions})) {
|
||||
if (parsed.protocol === 'chrome-extension:') {
|
||||
throw new Error(
|
||||
`Navigating to chrome-extension: URLs is not allowed without --categoryExtensions.`,
|
||||
);
|
||||
}
|
||||
throw new Error(`Navigating to ${parsed.protocol} URLs is not allowed.`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -134,6 +134,89 @@ describe('McpContext', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('drops pages reaching internal chrome or chrome-untrusted schemes by any route', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
const page = await context.newPage();
|
||||
const pageId = page.id;
|
||||
assert.strictEqual(context.getPageById(pageId), page);
|
||||
|
||||
const urlStub = sinon
|
||||
.stub(page.pptrPage, 'url')
|
||||
.returns('chrome://settings');
|
||||
try {
|
||||
await context.createPagesSnapshot();
|
||||
const listed = context.getPages();
|
||||
assert.ok(
|
||||
!listed.some(p => p.id === pageId),
|
||||
'page reaching chrome://settings should be dropped from listing',
|
||||
);
|
||||
assert.throws(() => context.getPageById(pageId), /No page found/);
|
||||
} finally {
|
||||
urlStub.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('drops pages with chrome-extension schemes unless categoryExtensions is enabled', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
const page = await context.newPage();
|
||||
const pageId = page.id;
|
||||
|
||||
const urlStub = sinon
|
||||
.stub(page.pptrPage, 'url')
|
||||
.returns('chrome-extension://some-ext-id/popup.html');
|
||||
try {
|
||||
await context.createPagesSnapshot();
|
||||
const listed = context.getPages();
|
||||
assert.ok(
|
||||
!listed.some(p => p.id === pageId),
|
||||
'extension page should be dropped from listing when categoryExtensions is disabled',
|
||||
);
|
||||
assert.throws(() => context.getPageById(pageId), /No page found/);
|
||||
} finally {
|
||||
urlStub.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps pages with chrome://newtab/ and chrome://inspect', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
const page = await context.newPage();
|
||||
const pageId = page.id;
|
||||
|
||||
const newtabStub = sinon
|
||||
.stub(page.pptrPage, 'url')
|
||||
.returns('chrome://newtab/');
|
||||
try {
|
||||
await context.createPagesSnapshot();
|
||||
const listed = context.getPages();
|
||||
assert.ok(
|
||||
listed.some(p => p.id === pageId),
|
||||
'chrome://newtab/ should be kept in listing',
|
||||
);
|
||||
assert.strictEqual(context.getPageById(pageId), page);
|
||||
} finally {
|
||||
newtabStub.restore();
|
||||
}
|
||||
|
||||
const inspectStub = sinon
|
||||
.stub(page.pptrPage, 'url')
|
||||
.returns('chrome://inspect/#devices');
|
||||
try {
|
||||
await context.createPagesSnapshot();
|
||||
const listed = context.getPages();
|
||||
assert.ok(
|
||||
listed.some(p => p.id === pageId),
|
||||
'chrome://inspect should be kept in listing',
|
||||
);
|
||||
assert.strictEqual(context.getPageById(pageId), page);
|
||||
} finally {
|
||||
inspectStub.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves uid from a non-selected page snapshot', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
// Page 1: set content and snapshot
|
||||
|
||||
+69
-1
@@ -11,7 +11,12 @@ import {describe, it} from 'node:test';
|
||||
|
||||
import {executablePath} from 'puppeteer';
|
||||
|
||||
import {detectDisplay, ensureBrowserConnected, launch} from '../src/browser.js';
|
||||
import {
|
||||
detectDisplay,
|
||||
ensureBrowserConnected,
|
||||
launch,
|
||||
makeTargetFilter,
|
||||
} from '../src/browser.js';
|
||||
import type {Browser} from '../src/third_party/index.js';
|
||||
|
||||
import {serverHooks} from './server.js';
|
||||
@@ -244,4 +249,67 @@ describe('browser', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeTargetFilter', () => {
|
||||
it('filters internal chrome and extension targets', () => {
|
||||
const filterWithoutExtensions = makeTargetFilter(false);
|
||||
const filterWithExtensions = makeTargetFilter(true);
|
||||
|
||||
const mockTarget = (url: string) => ({
|
||||
url: () => url,
|
||||
});
|
||||
|
||||
// Newtab and inspect allowances
|
||||
assert.strictEqual(
|
||||
filterWithoutExtensions(mockTarget('chrome://newtab/')),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
filterWithoutExtensions(mockTarget('chrome://inspect')),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
filterWithoutExtensions(mockTarget('chrome://inspect/#devices')),
|
||||
true,
|
||||
);
|
||||
|
||||
// Disallowed internal schemes
|
||||
assert.strictEqual(
|
||||
filterWithoutExtensions(mockTarget('chrome://settings')),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
filterWithoutExtensions(mockTarget('chrome://version')),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
filterWithoutExtensions(mockTarget('chrome-untrusted://terminal')),
|
||||
false,
|
||||
);
|
||||
|
||||
// Extensions toggle
|
||||
assert.strictEqual(
|
||||
filterWithoutExtensions(
|
||||
mockTarget('chrome-extension://abcdef/popup.html'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
filterWithExtensions(
|
||||
mockTarget('chrome-extension://abcdef/popup.html'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
// Web URLs
|
||||
assert.strictEqual(
|
||||
filterWithoutExtensions(mockTarget('https://example.com')),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
filterWithoutExtensions(mockTarget('about:blank')),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -319,6 +319,70 @@ describe('pages', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
it('rejects chrome: and chrome-untrusted: URLs', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const tool = newPage();
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await tool.handler(
|
||||
{params: {url: 'chrome://settings'}},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
},
|
||||
{
|
||||
message: 'Navigating to chrome: URLs is not allowed.',
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await tool.handler(
|
||||
{params: {url: 'chrome-untrusted://terminal'}},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
},
|
||||
{
|
||||
message: 'Navigating to chrome-untrusted: URLs is not allowed.',
|
||||
},
|
||||
);
|
||||
assert.strictEqual(context.getPages().length, 1);
|
||||
});
|
||||
});
|
||||
it('rejects chrome-extension: URLs unless categoryExtensions is enabled', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const tool = newPage();
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await tool.handler(
|
||||
{params: {url: 'chrome-extension://abcdef/popup.html'}},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Navigating to chrome-extension: URLs is not allowed without --categoryExtensions.',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
it('allows chrome://newtab/', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const tool = newPage();
|
||||
await tool.handler(
|
||||
{params: {url: 'chrome://newtab/'}},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
assert.ok(
|
||||
context
|
||||
.getSelectedMcpPage()
|
||||
.pptrPage.url()
|
||||
.startsWith('chrome://new'),
|
||||
);
|
||||
});
|
||||
});
|
||||
it('create a page in the background', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const originalPage = context.getPageById(1);
|
||||
@@ -1093,6 +1157,84 @@ describe('pages', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects chrome: and chrome-untrusted: URLs', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const tool = navigatePage();
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await tool.handler(
|
||||
{
|
||||
params: {url: 'chrome://settings'},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
},
|
||||
{
|
||||
message: 'Navigating to chrome: URLs is not allowed.',
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await tool.handler(
|
||||
{
|
||||
params: {url: 'chrome-untrusted://terminal'},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
},
|
||||
{
|
||||
message: 'Navigating to chrome-untrusted: URLs is not allowed.',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects chrome-extension: URLs unless categoryExtensions is enabled', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const tool = navigatePage();
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await tool.handler(
|
||||
{
|
||||
params: {url: 'chrome-extension://abcdef/popup.html'},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Navigating to chrome-extension: URLs is not allowed without --categoryExtensions.',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('allows chrome://newtab/', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const tool = navigatePage();
|
||||
await tool.handler(
|
||||
{
|
||||
params: {url: 'chrome://newtab/'},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
assert.ok(
|
||||
context
|
||||
.getSelectedMcpPage()
|
||||
.pptrPage.url()
|
||||
.startsWith('chrome://new'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('when dialog is open', async t => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const page = context.getSelectedMcpPage().pptrPage;
|
||||
|
||||
@@ -147,6 +147,42 @@ describe('slim', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects chrome: and chrome-untrusted: URLs', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const tool = navigate();
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await tool.handler(
|
||||
{
|
||||
params: {url: 'chrome://settings'},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
},
|
||||
{
|
||||
message: 'Navigating to chrome: URLs is not allowed.',
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await tool.handler(
|
||||
{
|
||||
params: {url: 'chrome-untrusted://terminal'},
|
||||
page: context.getSelectedMcpPage(),
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
},
|
||||
{
|
||||
message: 'Navigating to chrome-untrusted: URLs is not allowed.',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('with default options', async () => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const fixture = screenshots.basic;
|
||||
|
||||
@@ -187,6 +187,7 @@ export async function withMcpContext(
|
||||
navigationTimeout:
|
||||
options.navigationTimeout ??
|
||||
(process.platform === 'win32' ? 20000 : undefined),
|
||||
categoryExtensions: args?.categoryExtensions,
|
||||
},
|
||||
Locator,
|
||||
);
|
||||
|
||||
+284
-22
@@ -7,7 +7,7 @@
|
||||
import assert from 'node:assert';
|
||||
import {describe, it} from 'node:test';
|
||||
|
||||
import {isLocalhost, validateUrl} from '../../src/utils/url.js';
|
||||
import {isAllowedUrl, isLocalhost, validateUrl} from '../../src/utils/url.js';
|
||||
|
||||
describe('isLocalhost', () => {
|
||||
it('should return true for valid localhost and loopback URLs', () => {
|
||||
@@ -88,82 +88,344 @@ describe('isLocalhost', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const defaultOptions = {
|
||||
javascriptEvaluation: undefined,
|
||||
categoryExtensions: undefined,
|
||||
};
|
||||
|
||||
describe('validateUrl', () => {
|
||||
it('should return URL object for valid URLs', () => {
|
||||
assert.strictEqual(
|
||||
validateUrl('https://example.com').href,
|
||||
validateUrl('https://example.com', defaultOptions).href,
|
||||
'https://example.com/',
|
||||
);
|
||||
assert.strictEqual(
|
||||
validateUrl('http://localhost:3000').href,
|
||||
validateUrl('http://localhost:3000', defaultOptions).href,
|
||||
'http://localhost:3000/',
|
||||
);
|
||||
assert.strictEqual(validateUrl('about:blank').href, 'about:blank');
|
||||
assert.strictEqual(
|
||||
validateUrl('data:text/html,<div>test</div>').href,
|
||||
validateUrl('about:blank', defaultOptions).href,
|
||||
'about:blank',
|
||||
);
|
||||
assert.strictEqual(
|
||||
validateUrl('data:text/html,<div>test</div>', defaultOptions).href,
|
||||
'data:text/html,<div>test</div>',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject URLs that do not parse with new URL', () => {
|
||||
assert.throws(() => validateUrl('not a url'), /Invalid URL: "not a url"/);
|
||||
assert.throws(() => validateUrl(''), /Invalid URL: ""/);
|
||||
assert.throws(() => validateUrl('http://'), /Invalid URL: "http:\/\/"/);
|
||||
assert.throws(() => validateUrl('://'), /Invalid URL: ":\/\/"/);
|
||||
assert.throws(
|
||||
() => validateUrl('not a url', defaultOptions),
|
||||
/Invalid URL: "not a url"/,
|
||||
);
|
||||
assert.throws(() => validateUrl('', defaultOptions), /Invalid URL: ""/);
|
||||
assert.throws(
|
||||
() => validateUrl('http://', defaultOptions),
|
||||
/Invalid URL: "http:\/\/"/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateUrl('://', defaultOptions),
|
||||
/Invalid URL: ":\/\/"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow javascript, data, and vbscript URLs when javascriptEvaluation is true or omitted', () => {
|
||||
assert.strictEqual(
|
||||
validateUrl('javascript:alert(1)').protocol,
|
||||
validateUrl('javascript:alert(1)', defaultOptions).protocol,
|
||||
'javascript:',
|
||||
);
|
||||
assert.strictEqual(
|
||||
validateUrl('javascript:alert(1)', true).protocol,
|
||||
validateUrl('javascript:alert(1)', {
|
||||
javascriptEvaluation: true,
|
||||
categoryExtensions: undefined,
|
||||
}).protocol,
|
||||
'javascript:',
|
||||
);
|
||||
assert.strictEqual(
|
||||
validateUrl('data:text/html,<div>test</div>').protocol,
|
||||
validateUrl('data:text/html,<div>test</div>', defaultOptions).protocol,
|
||||
'data:',
|
||||
);
|
||||
assert.strictEqual(
|
||||
validateUrl('data:text/html,<div>test</div>', true).protocol,
|
||||
validateUrl('data:text/html,<div>test</div>', {
|
||||
javascriptEvaluation: true,
|
||||
categoryExtensions: undefined,
|
||||
}).protocol,
|
||||
'data:',
|
||||
);
|
||||
assert.strictEqual(validateUrl('vbscript:msgbox(1)').protocol, 'vbscript:');
|
||||
assert.strictEqual(
|
||||
validateUrl('vbscript:msgbox(1)', true).protocol,
|
||||
validateUrl('vbscript:msgbox(1)', defaultOptions).protocol,
|
||||
'vbscript:',
|
||||
);
|
||||
assert.strictEqual(
|
||||
validateUrl('vbscript:msgbox(1)', {
|
||||
javascriptEvaluation: true,
|
||||
categoryExtensions: undefined,
|
||||
}).protocol,
|
||||
'vbscript:',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject javascript, data, and vbscript URLs when javascriptEvaluation is false', () => {
|
||||
assert.throws(
|
||||
() => validateUrl('javascript:alert(1)', false),
|
||||
() =>
|
||||
validateUrl('javascript:alert(1)', {
|
||||
javascriptEvaluation: false,
|
||||
categoryExtensions: undefined,
|
||||
}),
|
||||
/Navigating to javascript: URLs is not allowed when JavaScript evaluation is disabled\./,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateUrl('JAVASCRIPT:alert(1)', false),
|
||||
() =>
|
||||
validateUrl('JAVASCRIPT:alert(1)', {
|
||||
javascriptEvaluation: false,
|
||||
categoryExtensions: undefined,
|
||||
}),
|
||||
/Navigating to javascript: URLs is not allowed when JavaScript evaluation is disabled\./,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateUrl('javascript:void(0)', false),
|
||||
() =>
|
||||
validateUrl('javascript:void(0)', {
|
||||
javascriptEvaluation: false,
|
||||
categoryExtensions: undefined,
|
||||
}),
|
||||
/Navigating to javascript: URLs is not allowed when JavaScript evaluation is disabled\./,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateUrl('data:text/html,<div>test</div>', false),
|
||||
() =>
|
||||
validateUrl('data:text/html,<div>test</div>', {
|
||||
javascriptEvaluation: false,
|
||||
categoryExtensions: undefined,
|
||||
}),
|
||||
/Navigating to data: URLs is not allowed when JavaScript evaluation is disabled\./,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateUrl('DATA:text/html,<div>test</div>', false),
|
||||
() =>
|
||||
validateUrl('DATA:text/html,<div>test</div>', {
|
||||
javascriptEvaluation: false,
|
||||
categoryExtensions: undefined,
|
||||
}),
|
||||
/Navigating to data: URLs is not allowed when JavaScript evaluation is disabled\./,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateUrl('vbscript:msgbox(1)', false),
|
||||
() =>
|
||||
validateUrl('vbscript:msgbox(1)', {
|
||||
javascriptEvaluation: false,
|
||||
categoryExtensions: undefined,
|
||||
}),
|
||||
/Navigating to vbscript: URLs is not allowed when JavaScript evaluation is disabled\./,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateUrl('VBSCRIPT:msgbox(1)', false),
|
||||
() =>
|
||||
validateUrl('VBSCRIPT:msgbox(1)', {
|
||||
javascriptEvaluation: false,
|
||||
categoryExtensions: undefined,
|
||||
}),
|
||||
/Navigating to vbscript: URLs is not allowed when JavaScript evaluation is disabled\./,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow chrome://newtab/ and chrome://inspect', () => {
|
||||
assert.strictEqual(
|
||||
validateUrl('chrome://newtab/', defaultOptions).href,
|
||||
'chrome://newtab/',
|
||||
);
|
||||
assert.strictEqual(
|
||||
validateUrl('chrome://newtab', defaultOptions).href,
|
||||
'chrome://newtab',
|
||||
);
|
||||
assert.strictEqual(
|
||||
validateUrl('chrome://inspect', defaultOptions).href,
|
||||
'chrome://inspect',
|
||||
);
|
||||
assert.strictEqual(
|
||||
validateUrl('chrome://inspect/#devices', defaultOptions).href,
|
||||
'chrome://inspect/#devices',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject chrome: and chrome-untrusted: URLs', () => {
|
||||
assert.throws(
|
||||
() => validateUrl('chrome://settings', defaultOptions),
|
||||
/Navigating to chrome: URLs is not allowed\./,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateUrl('chrome://version', defaultOptions),
|
||||
/Navigating to chrome: URLs is not allowed\./,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateUrl('chrome:version', defaultOptions),
|
||||
/Navigating to chrome: URLs is not allowed\./,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateUrl('CHROME://version', defaultOptions),
|
||||
/Navigating to chrome: URLs is not allowed\./,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateUrl('chrome-untrusted://terminal', defaultOptions),
|
||||
/Navigating to chrome-untrusted: URLs is not allowed\./,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateUrl('chrome-untrusted:terminal', defaultOptions),
|
||||
/Navigating to chrome-untrusted: URLs is not allowed\./,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject chrome-extension: URLs unless categoryExtensions is enabled', () => {
|
||||
assert.throws(
|
||||
() => validateUrl('chrome-extension://abcdef/popup.html', defaultOptions),
|
||||
/Navigating to chrome-extension: URLs is not allowed without --categoryExtensions\./,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateUrl('chrome-extension://abcdef/popup.html', {
|
||||
javascriptEvaluation: undefined,
|
||||
categoryExtensions: false,
|
||||
}),
|
||||
/Navigating to chrome-extension: URLs is not allowed without --categoryExtensions\./,
|
||||
);
|
||||
assert.strictEqual(
|
||||
validateUrl('chrome-extension://abcdef/popup.html', {
|
||||
javascriptEvaluation: undefined,
|
||||
categoryExtensions: true,
|
||||
}).href,
|
||||
'chrome-extension://abcdef/popup.html',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedUrl', () => {
|
||||
it('should allow chrome://newtab/ and chrome://inspect', () => {
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('chrome://newtab/', {categoryExtensions: undefined}),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('chrome://newtab', {categoryExtensions: undefined}),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('CHROME://newtab/', {categoryExtensions: undefined}),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('chrome://inspect', {categoryExtensions: undefined}),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('chrome://inspect/', {categoryExtensions: undefined}),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('chrome://inspect/#devices', {
|
||||
categoryExtensions: undefined,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('CHROME://inspect', {categoryExtensions: undefined}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should disallow internal chrome: and chrome-untrusted: URLs', () => {
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('chrome://settings', {categoryExtensions: undefined}),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('chrome://version', {categoryExtensions: undefined}),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('chrome:version', {categoryExtensions: undefined}),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('CHROME://settings', {categoryExtensions: undefined}),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('chrome-untrusted://terminal', {
|
||||
categoryExtensions: undefined,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('chrome-untrusted:terminal', {
|
||||
categoryExtensions: undefined,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('CHROME-UNTRUSTED://terminal', {
|
||||
categoryExtensions: undefined,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should disallow chrome-extension: URLs when extensions are disabled and allow when enabled', () => {
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('chrome-extension://abcdef/popup.html', {
|
||||
categoryExtensions: undefined,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('chrome-extension://abcdef/popup.html', {
|
||||
categoryExtensions: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('chrome-extension:abcdef', {categoryExtensions: false}),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('chrome-extension://abcdef/popup.html', {
|
||||
categoryExtensions: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('chrome-extension:abcdef', {categoryExtensions: true}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow standard web and navigation URLs', () => {
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('https://example.com', {categoryExtensions: undefined}),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('http://localhost:3000', {categoryExtensions: undefined}),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('about:blank', {categoryExtensions: undefined}),
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('data:text/html,<div>test</div>', {
|
||||
categoryExtensions: undefined,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false for unparseable URLs', () => {
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('not a url', {categoryExtensions: undefined}),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('', {categoryExtensions: undefined}),
|
||||
false,
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedUrl('://', {categoryExtensions: undefined}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user