diff --git a/src/McpContext.ts b/src/McpContext.ts index fbf9e5dc2..702a51929 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -66,6 +66,10 @@ interface McpContextOptions { performanceCrux: boolean; // Whether allowlist/blocklist is configured. hasNetworkBlockOrAllowlist?: boolean; + // URL patterns to block for direct resource loads. + blocklist?: string[]; + // URL patterns to allow for direct resource loads. + allowlist?: string[]; } const DEFAULT_TIMEOUT = 5_000; @@ -106,6 +110,8 @@ export class McpContext implements Context { #options: McpContextOptions; #heapSnapshotManager = new HeapSnapshotManager(); #roots: Root[] | undefined = undefined; + #blocklist: URLPattern[]; + #allowlist: URLPattern[] | undefined; private constructor( browser: Browser, @@ -123,6 +129,12 @@ export class McpContext implements Context { this.logger = logger; this.#locatorClass = locatorClass; this.#options = options; + this.#blocklist = + options.blocklist?.map(pattern => new URLPattern(pattern)) ?? []; + this.#allowlist = + options.allowlist && options.allowlist.length > 0 + ? options.allowlist.map(pattern => new URLPattern(pattern)) + : undefined; this.#networkCollector = new NetworkCollector(this.browser); @@ -958,7 +970,7 @@ export class McpContext implements Context { switch (url.protocol) { case 'https:': case 'http:': { - // TODO: Verify allow/block list + this.#validateNetworkResource(url); const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to load resource: ${url}`); @@ -976,6 +988,31 @@ export class McpContext implements Context { } } + #validateNetworkResource(url: URL): void { + const urlString = url.href; + for (const pattern of this.#blocklist) { + if (pattern.test(urlString)) { + throw new Error( + `Access denied: URL ${urlString} is blocked by network blocklist.`, + ); + } + } + + if (!this.#allowlist) { + return; + } + + for (const pattern of this.#allowlist) { + if (pattern.test(urlString)) { + return; + } + } + + throw new Error( + `Access denied: URL ${urlString} is not allowed by network allowlist.`, + ); + } + async getHeapSnapshotEdges( filePath: string, nodeId: number, diff --git a/src/bin/chrome-devtools-mcp-cli-options.ts b/src/bin/chrome-devtools-mcp-cli-options.ts index 6c446873c..01d2b3cde 100644 --- a/src/bin/chrome-devtools-mcp-cli-options.ts +++ b/src/bin/chrome-devtools-mcp-cli-options.ts @@ -216,12 +216,30 @@ export const cliOptions = { describe: 'Restricts network access by blocking specified URL patterns (uses https://urlpattern.spec.whatwg.org/). Silently detaches from targets with blocked URLs upon connection, and blocks runtime requests (including navigations and subresources). Accepts an array of patterns.', conflicts: ['allowedUrlPattern'], + coerce: (patterns: unknown[] | undefined) => { + if (!patterns) { + return; + } + for (const pattern of patterns) { + new URLPattern(String(pattern)); + } + return patterns.map(String); + }, }, allowedUrlPattern: { type: 'array', describe: 'Restricts network access by allowing only specified URL patterns (uses https://urlpattern.spec.whatwg.org/). Requires Chrome 149+. Silently detaches from targets with unallowed URLs upon connection, and blocks runtime requests (including navigations and subresources). Accepts an array of patterns.', conflicts: ['blockedUrlPattern'], + coerce: (patterns: unknown[] | undefined) => { + if (!patterns) { + return; + } + for (const pattern of patterns) { + new URLPattern(String(pattern)); + } + return patterns.map(String); + }, }, ignoreDefaultChromeArg: { type: 'array', diff --git a/src/index.ts b/src/index.ts index fe725af62..a8203e9fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -148,6 +148,8 @@ export async function createMcpServer( (blocklist && blocklist.length > 0) || (allowlist && allowlist.length > 0), ), + blocklist, + allowlist, }); await updateRoots(); } diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index 5424600c6..a37154ca6 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -18,9 +18,12 @@ import {TextSnapshot} from '../src/TextSnapshot.js'; import type {HTTPResponse} from '../src/third_party/index.js'; import type {TraceResult} from '../src/trace-processing/parse.js'; +import {serverHooks} from './server.js'; import {getMockRequest, html, withMcpContext} from './utils.js'; describe('McpContext', () => { + const server = serverHooks(); + afterEach(() => { sinon.restore(); }); @@ -315,5 +318,73 @@ describe('McpContext', () => { } }); }); + + it('http protocol respects the network blocklist', async () => { + server.addRoute('/blocked.map', (_req, res) => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end('{}'); + }); + + const blockedUrl = server.getRoute('/blocked.map'); + await withMcpContext( + async (_response, context) => { + await assert.rejects( + context.loadResource(blockedUrl), + /blocked by network blocklist/, + ); + }, + { + blockedUrlPattern: [blockedUrl], + }, + ); + }); + + it('http protocol respects the network allowlist', async () => { + server.addRoute('/allowed.map', (_req, res) => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end('{"version":3}'); + }); + server.addRoute('/blocked.map', (_req, res) => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end('{}'); + }); + + const allowedUrl = server.getRoute('/allowed.map'); + const blockedUrl = server.getRoute('/blocked.map'); + await withMcpContext( + async (_response, context) => { + assert.strictEqual( + await context.loadResource(allowedUrl), + '{"version":3}', + ); + await assert.rejects( + context.loadResource(blockedUrl), + /not allowed by network allowlist/, + ); + }, + { + allowedUrlPattern: [allowedUrl], + }, + ); + }); + + it('http protocol ignores an empty network allowlist', async () => { + server.addRoute('/source.map', (_req, res) => { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end('{"version":3}'); + }); + + await withMcpContext( + async (_response, context) => { + assert.strictEqual( + await context.loadResource(server.getRoute('/source.map')), + '{"version":3}', + ); + }, + { + allowedUrlPattern: [], + }, + ); + }); }); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 25f07d18e..731dae9fd 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -7,7 +7,10 @@ import assert from 'node:assert'; import {describe, it} from 'node:test'; -import {parseArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js'; +import { + cliOptions, + parseArguments, +} from '../src/bin/chrome-devtools-mcp-cli-options.js'; describe('cli args parsing', () => { const defaultArgs = { @@ -391,6 +394,12 @@ describe('cli args parsing', () => { ]); }); + it('rejects invalid blocked-url-pattern values', async () => { + assert.throws(() => + cliOptions.blockedUrlPattern.coerce(['https://[invalid']), + ); + }); + it('parses allowed-url-pattern flags as array', async () => { const defaultArgs = parseArguments('1.0.0', ['node', 'main.js']); assert.strictEqual(defaultArgs.allowedUrlPattern, undefined); @@ -435,4 +444,10 @@ describe('cli args parsing', () => { 'https://b.com/*', ]); }); + + it('rejects invalid allowed-url-pattern values', async () => { + assert.throws(() => + cliOptions.allowedUrlPattern.coerce(['https://[invalid']), + ); + }); }); diff --git a/tests/utils.ts b/tests/utils.ts index 98e20512d..deff50b91 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -140,6 +140,8 @@ export async function withMcpContext( (options.blockedUrlPattern && options.blockedUrlPattern.length > 0) || (options.allowedUrlPattern && options.allowedUrlPattern.length > 0), ), + blocklist: options.blockedUrlPattern, + allowlist: options.allowedUrlPattern, }, Locator, );