Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ interface McpContextOptions {
performanceCrux: boolean;
// Whether allowlist/blocklist is configured.
hasNetworkBlockOrAllowlist?: boolean;
// URL patterns to block for direct resource loads.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now as we pass options into context, we don't need to pass hasNetworkBlockOrAllowlist and we can calculate and assign it in the context constructor ourself. Can you please remove it from McpContextOptions

blocklist?: string[];
// URL patterns to allow for direct resource loads.
allowlist?: string[];
}

const DEFAULT_TIMEOUT = 5_000;
Expand Down Expand Up @@ -106,6 +110,8 @@ export class McpContext implements Context {
#options: McpContextOptions;
#heapSnapshotManager = new HeapSnapshotManager();
#roots: Root[] | undefined = undefined;
#blocklist: URLPattern[];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can have the same pattern of

Suggested change
#blocklist: URLPattern[];
#blocklist: URLPattern[] | undefined;

And check for existence in #validateNetworkResource

#allowlist: URLPattern[] | undefined;

private constructor(
browser: Browser,
Expand All @@ -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;
Comment on lines +134 to +137

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
this.#allowlist =
options.allowlist && options.allowlist.length > 0
? options.allowlist.map(pattern => new URLPattern(pattern))
: undefined;
this.#allowlist = options.allowlist?.map(pattern => new URLPattern(pattern));


this.#networkCollector = new NetworkCollector(this.browser);

Expand Down Expand Up @@ -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}`);
Expand All @@ -976,6 +988,31 @@ export class McpContext implements Context {
}
}

#validateNetworkResource(url: URL): void {
const urlString = url.href;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed for this variable we can pass the url directly to the pattern.test

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,
Expand Down
18 changes: 18 additions & 0 deletions src/bin/chrome-devtools-mcp-cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's extract this is a helper function - verifyUrlPatterns

if (!patterns) {
return;
}
for (const pattern of patterns) {
new URLPattern(String(pattern));
}
return patterns.map(String);
},
},
ignoreDefaultChromeArg: {
type: 'array',
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ export async function createMcpServer(
(blocklist && blocklist.length > 0) ||
(allowlist && allowlist.length > 0),
),
blocklist,
allowlist,
});
await updateRoots();
}
Expand Down
71 changes: 71 additions & 0 deletions tests/McpContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move this under describe('loadResource', () => {


afterEach(() => {
sinon.restore();
});
Expand Down Expand Up @@ -315,5 +318,73 @@ describe('McpContext', () => {
}
});
});

it('http protocol respects the network blocklist', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it('http protocol respects the network blocklist', async () => {
it('should block remote resources matching the 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 () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it('http protocol respects the network allowlist', async () => {
it('should only load remote resources matching the 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 () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it('http protocol ignores an empty network allowlist', async () => {
it('should allow all remote resources if the allowlist is empty', 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: [],
},
);
});
});
});
17 changes: 16 additions & 1 deletion tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -391,6 +394,12 @@ describe('cli args parsing', () => {
]);
});

it('rejects invalid blocked-url-pattern values', async () => {
assert.throws(() =>
cliOptions.blockedUrlPattern.coerce(['https://[invalid']),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not test the function directly but use the parseArguments to validate the parsing. Similar to how it's done for other tests.

);
});

it('parses allowed-url-pattern flags as array', async () => {
const defaultArgs = parseArguments('1.0.0', ['node', 'main.js']);
assert.strictEqual(defaultArgs.allowedUrlPattern, undefined);
Expand Down Expand Up @@ -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']),
);
});
});
2 changes: 2 additions & 0 deletions tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down