Skip to content

Commit d94199e

Browse files
authored
chore(mcp): re-add --allow-origins (#38291)
1 parent 88b6a53 commit d94199e

File tree

5 files changed

+192
-1
lines changed

5 files changed

+192
-1
lines changed

packages/playwright/src/mcp/browser/config.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ type ViewportSize = { width: number; height: number };
3131

3232
export type CLIOptions = {
3333
allowedHosts?: string[];
34+
allowedOrigins?: string[];
35+
blockedOrigins?: string[];
3436
blockServiceWorkers?: boolean;
3537
browser?: string;
3638
caps?: string[];
@@ -78,6 +80,10 @@ export const defaultConfig: FullConfig = {
7880
viewport: null,
7981
},
8082
},
83+
network: {
84+
allowedOrigins: undefined,
85+
blockedOrigins: undefined,
86+
},
8187
server: {},
8288
saveTrace: false,
8389
timeouts: {
@@ -94,6 +100,7 @@ export type FullConfig = Config & {
94100
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
95101
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
96102
},
103+
network: NonNullable<Config['network']>,
97104
saveTrace: boolean;
98105
server: NonNullable<Config['server']>,
99106
timeouts: {
@@ -221,6 +228,10 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
221228
allowedHosts: cliOptions.allowedHosts,
222229
},
223230
capabilities: cliOptions.caps as ToolCapability[],
231+
network: {
232+
allowedOrigins: cliOptions.allowedOrigins,
233+
blockedOrigins: cliOptions.blockedOrigins,
234+
},
224235
saveSession: cliOptions.saveSession,
225236
saveTrace: cliOptions.saveTrace,
226237
saveVideo: cliOptions.saveVideo,
@@ -241,6 +252,8 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
241252
function configFromEnv(): Config {
242253
const options: CLIOptions = {};
243254
options.allowedHosts = commaSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_HOSTNAMES);
255+
options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS);
256+
options.blockedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_BLOCKED_ORIGINS);
244257
options.blockServiceWorkers = envToBoolean(process.env.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS);
245258
options.browser = envToString(process.env.PLAYWRIGHT_MCP_BROWSER);
246259
options.caps = commaSeparatedList(process.env.PLAYWRIGHT_MCP_CAPS);
@@ -358,6 +371,10 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
358371
...pickDefined(base),
359372
...pickDefined(overrides),
360373
browser,
374+
network: {
375+
...pickDefined(base.network),
376+
...pickDefined(overrides.network),
377+
},
361378
server: {
362379
...pickDefined(base.server),
363380
...pickDefined(overrides.server),
@@ -369,6 +386,12 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
369386
} as FullConfig;
370387
}
371388

389+
export function semicolonSeparatedList(value: string | undefined): string[] | undefined {
390+
if (!value)
391+
return undefined;
392+
return value.split(';').map(v => v.trim());
393+
}
394+
372395
export function commaSeparatedList(value: string | undefined): string[] | undefined {
373396
if (!value)
374397
return undefined;

packages/playwright/src/mcp/browser/context.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,20 @@ export class Context {
202202
Context._allContexts.delete(this);
203203
}
204204

205+
private async _setupRequestInterception(context: playwright.BrowserContext) {
206+
if (this.config.network?.allowedOrigins?.length) {
207+
await context.route('**', route => route.abort('blockedbyclient'));
208+
209+
for (const origin of this.config.network.allowedOrigins)
210+
await context.route(originOrHostGlob(origin), route => route.continue());
211+
}
212+
213+
if (this.config.network?.blockedOrigins?.length) {
214+
for (const origin of this.config.network.blockedOrigins)
215+
await context.route(originOrHostGlob(origin), route => route.abort('blockedbyclient'));
216+
}
217+
}
218+
205219
async ensureBrowserContext(): Promise<playwright.BrowserContext> {
206220
const { browserContext } = await this._ensureBrowserContext();
207221
return browserContext;
@@ -226,6 +240,7 @@ export class Context {
226240
selectors.setTestIdAttribute(this.config.testIdAttribute);
227241
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal, this._runningToolName);
228242
const { browserContext } = result;
243+
await this._setupRequestInterception(browserContext);
229244
if (this.sessionLog)
230245
await InputRecorder.create(this, browserContext);
231246
for (const page of browserContext.pages())
@@ -252,6 +267,18 @@ export class Context {
252267
}
253268
}
254269

270+
function originOrHostGlob(originOrHost: string) {
271+
try {
272+
const url = new URL(originOrHost);
273+
// localhost:1234 will parse as protocol 'localhost:' and 'null' origin.
274+
if (url.origin !== 'null')
275+
return `${url.origin}/**`;
276+
} catch {
277+
}
278+
// Support for legacy host-only mode.
279+
return `*://${originOrHost}/**`;
280+
}
281+
255282
export class InputRecorder {
256283
private _context: Context;
257284
private _browserContext: playwright.BrowserContext;

packages/playwright/src/mcp/config.d.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,18 @@ export type Config = {
142142
*/
143143
outputDir?: string;
144144

145+
network?: {
146+
/**
147+
* List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
148+
*/
149+
allowedOrigins?: string[];
150+
151+
/**
152+
* List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
153+
*/
154+
blockedOrigins?: string[];
155+
};
156+
145157
/**
146158
* Specify the attribute to use for test ids, defaults to "data-testid".
147159
*/

packages/playwright/src/mcp/program.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { colors, ProgramOption } from 'playwright-core/lib/utilsBundle';
2222
import { registry } from 'playwright-core/lib/server';
2323

2424
import * as mcpServer from './sdk/server';
25-
import { commaSeparatedList, dotenvFileLoader, headerParser, numberParser, resolutionParser, resolveCLIConfig } from './browser/config';
25+
import { commaSeparatedList, dotenvFileLoader, headerParser, numberParser, resolutionParser, resolveCLIConfig, semicolonSeparatedList } from './browser/config';
2626
import { setupExitWatchdog } from './browser/watchdog';
2727
import { contextFactory } from './browser/browserContextFactory';
2828
import { ProxyBackend } from './sdk/proxyBackend';
@@ -35,6 +35,8 @@ import type { MCPProvider } from './sdk/proxyBackend';
3535
export function decorateCommand(command: Command, version: string) {
3636
command
3737
.option('--allowed-hosts <hosts...>', 'comma-separated list of hosts this server is allowed to serve from. Defaults to the host the server is bound to. Pass \'*\' to disable the host check.', commaSeparatedList)
38+
.option('--allowed-origins <origins>', 'semicolon-separated list of TRUSTED origins to allow the browser to request. Default is to allow all.\nImportant: *does not* serve as a security boundary and *does not* affect redirects. ', semicolonSeparatedList)
39+
.option('--blocked-origins <origins>', 'semicolon-separated list of TRUSTED origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.\nImportant: *does not* serve as a security boundary and *does not* affect redirects.', semicolonSeparatedList)
3840
.option('--block-service-workers', 'block service workers')
3941
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
4042
.option('--caps <caps>', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf.', commaSeparatedList)

tests/mcp/request-blocking.spec.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
18+
import { test, expect } from './fixtures';
19+
20+
const BLOCK_MESSAGE = /Blocked by Web Inspector|NS_ERROR_FAILURE|net::ERR_BLOCKED_BY_CLIENT/g;
21+
22+
const fetchPage = async (client: Client, url: string) => {
23+
const result = await client.callTool({
24+
name: 'browser_navigate',
25+
arguments: {
26+
url,
27+
},
28+
});
29+
30+
return JSON.stringify(result, null, 2);
31+
};
32+
33+
test('default to allow all', async ({ server, client }) => {
34+
server.setContent('/ppp', 'content:PPP', 'text/html');
35+
const result = await fetchPage(client, server.PREFIX + '/ppp');
36+
expect(result).toContain('content:PPP');
37+
});
38+
39+
test('blocked works (hostname)', async ({ startClient }) => {
40+
const { client } = await startClient({
41+
args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev']
42+
});
43+
const result = await fetchPage(client, 'https://example.com/');
44+
expect(result).toMatch(BLOCK_MESSAGE);
45+
});
46+
47+
test('blocked works (origin)', async ({ startClient }) => {
48+
const { client } = await startClient({
49+
args: ['--blocked-origins', 'https://microsoft.com;https://example.com;https://playwright.dev']
50+
});
51+
const result = await fetchPage(client, 'https://example.com/');
52+
expect(result).toMatch(BLOCK_MESSAGE);
53+
});
54+
55+
test('allowed works (hostname)', async ({ server, startClient }) => {
56+
server.setContent('/ppp', 'content:PPP', 'text/html');
57+
const { client } = await startClient({
58+
args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
59+
});
60+
const result = await fetchPage(client, server.PREFIX + '/ppp');
61+
expect(result).toContain('content:PPP');
62+
});
63+
64+
test('allowed works (origin)', async ({ server, startClient }) => {
65+
server.setContent('/ppp', 'content:PPP', 'text/html');
66+
const { client } = await startClient({
67+
args: ['--allowed-origins', `https://microsoft.com;${new URL(server.PREFIX).origin};https://playwright.dev`]
68+
});
69+
const result = await fetchPage(client, server.PREFIX + '/ppp');
70+
expect(result).toContain('content:PPP');
71+
});
72+
73+
test('blocked takes precedence (hostname)', async ({ startClient }) => {
74+
const { client } = await startClient({
75+
args: [
76+
'--blocked-origins', 'example.com',
77+
'--allowed-origins', 'example.com',
78+
],
79+
});
80+
const result = await fetchPage(client, 'https://example.com/');
81+
expect(result).toMatch(BLOCK_MESSAGE);
82+
});
83+
84+
test('blocked takes precedence (origin)', async ({ startClient }) => {
85+
const { client } = await startClient({
86+
args: [
87+
'--blocked-origins', 'https://example.com',
88+
'--allowed-origins', 'https://example.com',
89+
],
90+
});
91+
const result = await fetchPage(client, 'https://example.com/');
92+
expect(result).toMatch(BLOCK_MESSAGE);
93+
});
94+
95+
test('allowed without blocked blocks all non-explicitly specified origins (hostname)', async ({ startClient }) => {
96+
const { client } = await startClient({
97+
args: ['--allowed-origins', 'playwright.dev'],
98+
});
99+
const result = await fetchPage(client, 'https://example.com/');
100+
expect(result).toMatch(BLOCK_MESSAGE);
101+
});
102+
103+
test('allowed without blocked blocks all non-explicitly specified origins (origin)', async ({ startClient }) => {
104+
const { client } = await startClient({
105+
args: ['--allowed-origins', 'https://playwright.dev'],
106+
});
107+
const result = await fetchPage(client, 'https://example.com/');
108+
expect(result).toMatch(BLOCK_MESSAGE);
109+
});
110+
111+
test('blocked without allowed allows non-explicitly specified origins (hostname)', async ({ server, startClient }) => {
112+
server.setContent('/ppp', 'content:PPP', 'text/html');
113+
const { client } = await startClient({
114+
args: ['--blocked-origins', 'example.com'],
115+
});
116+
const result = await fetchPage(client, server.PREFIX + '/ppp');
117+
expect(result).toContain('content:PPP');
118+
});
119+
120+
test('blocked without allowed allows non-explicitly specified origins (origin)', async ({ server, startClient }) => {
121+
server.setContent('/ppp', 'content:PPP', 'text/html');
122+
const { client } = await startClient({
123+
args: ['--blocked-origins', 'https://example.com'],
124+
});
125+
const result = await fetchPage(client, server.PREFIX + '/ppp');
126+
expect(result).toContain('content:PPP');
127+
});

0 commit comments

Comments
 (0)