Skip to content

Commit ba91ddf

Browse files
committed
add: --allowedOrigins & --blockedOrigins options
1 parent 1649336 commit ba91ddf

File tree

9 files changed

+364
-9
lines changed

9 files changed

+364
-9
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,14 @@ The Chrome DevTools MCP server supports the following configuration option:
268268
Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs. Useful for submitting bug reports.
269269
- **Type:** string
270270

271+
- **`--allowedOrigins`**
272+
Semicolon-separated list of origins the browser is allowed to request. If not specified, all origins are allowed (except those in blockedOrigins). Example: https://example.com;https://api.example.com
273+
- **Type:** string
274+
275+
- **`--blockedOrigins`**
276+
Semicolon-separated list of origins the browser is blocked from requesting. Takes precedence over allowedOrigins. Example: https://ads.example.com;https://tracker.example.com
277+
- **Type:** string
278+
271279
<!-- END AUTO GENERATED OPTIONS -->
272280

273281
Pass them via the `args` property in the JSON configuration. For example:

package-lock.json

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
"core-js": "3.45.1",
4242
"debug": "4.4.3",
4343
"puppeteer-core": "24.22.3",
44-
"yargs": "18.0.0"
44+
"yargs": "18.0.0",
45+
"zod": "3.24.1"
4546
},
4647
"devDependencies": {
4748
"@eslint/js": "^9.35.0",

src/McpContext.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {takeSnapshot} from './tools/snapshot.js';
2525
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
2626
import type {Context} from './tools/ToolDefinition.js';
2727
import type {TraceResult} from './trace-processing/parse.js';
28+
import type {UrlValidator} from './utils/urlValidator.js';
2829
import {WaitForHelper} from './WaitForHelper.js';
2930

3031
export interface TextSnapshotNode extends SerializedAXNode {
@@ -77,10 +78,16 @@ export class McpContext implements Context {
7778

7879
#nextSnapshotId = 1;
7980
#traceResults: TraceResult[] = [];
81+
#urlValidator?: UrlValidator;
8082

81-
private constructor(browser: Browser, logger: Debugger) {
83+
private constructor(
84+
browser: Browser,
85+
logger: Debugger,
86+
urlValidator?: UrlValidator,
87+
) {
8288
this.browser = browser;
8389
this.logger = logger;
90+
this.#urlValidator = urlValidator;
8491

8592
this.#networkCollector = new NetworkCollector(
8693
this.browser,
@@ -109,10 +116,52 @@ export class McpContext implements Context {
109116
this.setSelectedPageIdx(0);
110117
await this.#networkCollector.init();
111118
await this.#consoleCollector.init();
119+
if (this.#urlValidator?.hasRestrictions()) {
120+
await this.#setupRequestInterception();
121+
}
122+
}
123+
124+
async #setupRequestInterception() {
125+
const pages = await this.browser.pages();
126+
for (const page of pages) {
127+
await this.#enableRequestInterceptionForPage(page);
128+
}
129+
130+
this.browser.on('targetcreated', async target => {
131+
const page = await target.page();
132+
if (page) {
133+
await this.#enableRequestInterceptionForPage(page);
134+
}
135+
});
112136
}
113137

114-
static async from(browser: Browser, logger: Debugger) {
115-
const context = new McpContext(browser, logger);
138+
async #enableRequestInterceptionForPage(page: Page) {
139+
try {
140+
await page.setRequestInterception(true);
141+
142+
page.on('request', interceptedRequest => {
143+
if (interceptedRequest.isInterceptResolutionHandled()) {
144+
return;
145+
}
146+
147+
const url = interceptedRequest.url();
148+
if (this.#urlValidator && !this.#urlValidator.isAllowed(url)) {
149+
void interceptedRequest.abort('blockedbyclient', 0);
150+
} else {
151+
void interceptedRequest.continue({}, 0);
152+
}
153+
});
154+
} catch (error) {
155+
this.logger(`Failed to enable request interception for page: ${error}`);
156+
}
157+
}
158+
159+
static async from(
160+
browser: Browser,
161+
logger: Debugger,
162+
urlValidator?: UrlValidator,
163+
) {
164+
const context = new McpContext(browser, logger, urlValidator);
116165
await context.#init();
117166
return context;
118167
}
@@ -133,6 +182,9 @@ export class McpContext implements Context {
133182
this.setSelectedPageIdx(pages.indexOf(page));
134183
this.#networkCollector.addPage(page);
135184
this.#consoleCollector.addPage(page);
185+
if (this.#urlValidator?.hasRestrictions()) {
186+
await this.#enableRequestInterceptionForPage(page);
187+
}
136188
return page;
137189
}
138190
async closePage(pageIdx: number): Promise<void> {

src/cli.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@ export const cliOptions = {
5454
describe:
5555
'Path to a file to write debug logs to. Set the env variable `DEBUG` to `*` to enable verbose logs. Useful for submitting bug reports.',
5656
},
57+
allowedOrigins: {
58+
type: 'string' as const,
59+
describe:
60+
'Semicolon-separated list of origins the browser is allowed to request. If not specified, all origins are allowed (except those in blockedOrigins). Example: https://example.com;https://api.example.com',
61+
},
62+
blockedOrigins: {
63+
type: 'string' as const,
64+
describe:
65+
'Semicolon-separated list of origins the browser is blocked from requesting. Takes precedence over allowedOrigins. Example: https://ads.example.com;https://tracker.example.com',
66+
},
5767
};
5868

5969
export function parseArguments(version: string, argv = process.argv) {
@@ -78,6 +88,14 @@ export function parseArguments(version: string, argv = process.argv) {
7888
['$0 --channel dev', 'Use Chrome Dev installed on this system'],
7989
['$0 --channel stable', 'Use stable Chrome installed on this system'],
8090
['$0 --logFile /tmp/log.txt', 'Save logs to a file'],
91+
[
92+
'$0 --allowedOrigins "https://example.com;https://api.example.com"',
93+
'Only allow requests to specific origins',
94+
],
95+
[
96+
'$0 --blockedOrigins "https://ads.example.com;https://tracker.com"',
97+
'Block requests to specific origins',
98+
],
8199
['$0 --help', 'Print CLI options'],
82100
]);
83101

src/main.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import * as screenshotTools from './tools/screenshot.js';
3232
import * as scriptTools from './tools/script.js';
3333
import * as snapshotTools from './tools/snapshot.js';
3434
import type {ToolDefinition} from './tools/ToolDefinition.js';
35+
import {UrlValidator} from './utils/urlValidator.js';
3536

3637
function readPackageJson(): {version?: string} {
3738
const currentDir = import.meta.dirname;
@@ -55,6 +56,14 @@ export const args = parseArguments(version);
5556
const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
5657

5758
logger(`Starting Chrome DevTools MCP Server v${version}`);
59+
60+
const allowedOrigins = UrlValidator.parseOrigins(args.allowedOrigins);
61+
const blockedOrigins = UrlValidator.parseOrigins(args.blockedOrigins);
62+
const urlValidator =
63+
allowedOrigins.length > 0 || blockedOrigins.length > 0
64+
? new UrlValidator({allowedOrigins, blockedOrigins}, logger)
65+
: undefined;
66+
5867
const server = new McpServer(
5968
{
6069
name: 'chrome_devtools',
@@ -79,7 +88,7 @@ async function getContext(): Promise<McpContext> {
7988
logFile,
8089
});
8190
if (context?.browser !== browser) {
82-
context = await McpContext.from(browser, logger);
91+
context = await McpContext.from(browser, logger, urlValidator);
8392
}
8493
return context;
8594
}

src/utils/urlValidator.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type {Debugger} from 'debug';
8+
9+
export class UrlValidator {
10+
#allowedOrigins: string[];
11+
#blockedOrigins: string[];
12+
#logger: Debugger;
13+
14+
constructor(
15+
options: {
16+
allowedOrigins?: string[];
17+
blockedOrigins?: string[];
18+
},
19+
logger: Debugger,
20+
) {
21+
this.#allowedOrigins = options.allowedOrigins ?? [];
22+
this.#blockedOrigins = options.blockedOrigins ?? [];
23+
this.#logger = logger;
24+
25+
if (this.#allowedOrigins.length > 0) {
26+
this.#logger(
27+
`URL validation enabled. Allowed origins: ${this.#allowedOrigins.join(', ')}`,
28+
);
29+
}
30+
if (this.#blockedOrigins.length > 0) {
31+
this.#logger(
32+
`URL validation enabled. Blocked origins: ${this.#blockedOrigins.join(', ')}`,
33+
);
34+
}
35+
}
36+
37+
static parseOrigins(originsString?: string): string[] {
38+
if (!originsString) {
39+
return [];
40+
}
41+
return originsString
42+
.split(';')
43+
.map(o => o.trim())
44+
.filter(o => o.length > 0);
45+
}
46+
47+
isAllowed(url: string): boolean {
48+
if (this.#isSpecialUrl(url)) {
49+
return true;
50+
}
51+
52+
try {
53+
const origin = new URL(url).origin;
54+
55+
if (this.#matchesAnyOrigin(origin, this.#blockedOrigins)) {
56+
this.#logger(`Blocked request to ${url} (origin: ${origin})`);
57+
return false;
58+
}
59+
60+
if (this.#allowedOrigins.length === 0) {
61+
return true;
62+
}
63+
64+
const allowed = this.#matchesAnyOrigin(origin, this.#allowedOrigins);
65+
if (!allowed) {
66+
this.#logger(
67+
`Blocked request to ${url} (origin: ${origin} not in allowlist)`,
68+
);
69+
}
70+
return allowed;
71+
} catch {
72+
return true;
73+
}
74+
}
75+
76+
#isSpecialUrl(url: string): boolean {
77+
const lowerUrl = url.toLowerCase();
78+
return (
79+
lowerUrl.startsWith('about:') ||
80+
lowerUrl.startsWith('data:') ||
81+
lowerUrl.startsWith('blob:') ||
82+
lowerUrl.startsWith('file:')
83+
);
84+
}
85+
86+
#matchesAnyOrigin(origin: string, patterns: string[]): boolean {
87+
for (const pattern of patterns) {
88+
if (this.#matchesOriginPattern(origin, pattern)) {
89+
return true;
90+
}
91+
}
92+
return false;
93+
}
94+
95+
#matchesOriginPattern(origin: string, pattern: string): boolean {
96+
if (origin === pattern) {
97+
return true;
98+
}
99+
100+
if (pattern.includes('*')) {
101+
const regex = this.#patternToRegex(pattern);
102+
return regex.test(origin);
103+
}
104+
105+
return false;
106+
}
107+
108+
#patternToRegex(pattern: string): RegExp {
109+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
110+
const regexPattern = escaped.replace(/\*/g, '[^\\/]+');
111+
return new RegExp(`^${regexPattern}$`);
112+
}
113+
114+
hasRestrictions(): boolean {
115+
return this.#allowedOrigins.length > 0 || this.#blockedOrigins.length > 0;
116+
}
117+
}

tests/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66
import logger from 'debug';
7+
import type {Debugger} from 'debug';
78
import type {Browser} from 'puppeteer';
89
import puppeteer from 'puppeteer';
910
import type {HTTPRequest, HTTPResponse} from 'puppeteer-core';
1011

1112
import {McpContext} from '../src/McpContext.js';
1213
import {McpResponse} from '../src/McpResponse.js';
1314

15+
export function createLogger(namespace = 'test'): Debugger {
16+
return logger(namespace);
17+
}
18+
1419
let browser: Browser | undefined;
1520

1621
export async function withBrowser(

0 commit comments

Comments
 (0)