Skip to content
Merged
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
23 changes: 23 additions & 0 deletions packages/playwright/src/mcp/browser/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ type ViewportSize = { width: number; height: number };

export type CLIOptions = {
allowedHosts?: string[];
allowedOrigins?: string[];
blockedOrigins?: string[];
blockServiceWorkers?: boolean;
browser?: string;
caps?: string[];
Expand Down Expand Up @@ -78,6 +80,10 @@ export const defaultConfig: FullConfig = {
viewport: null,
},
},
network: {
allowedOrigins: undefined,
blockedOrigins: undefined,
},
server: {},
saveTrace: false,
timeouts: {
Expand All @@ -94,6 +100,7 @@ export type FullConfig = Config & {
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
},
network: NonNullable<Config['network']>,
saveTrace: boolean;
server: NonNullable<Config['server']>,
timeouts: {
Expand Down Expand Up @@ -221,6 +228,10 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
allowedHosts: cliOptions.allowedHosts,
},
capabilities: cliOptions.caps as ToolCapability[],
network: {
allowedOrigins: cliOptions.allowedOrigins,
blockedOrigins: cliOptions.blockedOrigins,
},
saveSession: cliOptions.saveSession,
saveTrace: cliOptions.saveTrace,
saveVideo: cliOptions.saveVideo,
Expand All @@ -241,6 +252,8 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
function configFromEnv(): Config {
const options: CLIOptions = {};
options.allowedHosts = commaSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_HOSTNAMES);
options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS);
options.blockedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_BLOCKED_ORIGINS);
options.blockServiceWorkers = envToBoolean(process.env.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS);
options.browser = envToString(process.env.PLAYWRIGHT_MCP_BROWSER);
options.caps = commaSeparatedList(process.env.PLAYWRIGHT_MCP_CAPS);
Expand Down Expand Up @@ -358,6 +371,10 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
...pickDefined(base),
...pickDefined(overrides),
browser,
network: {
...pickDefined(base.network),
...pickDefined(overrides.network),
},
server: {
...pickDefined(base.server),
...pickDefined(overrides.server),
Expand All @@ -369,6 +386,12 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
} as FullConfig;
}

export function semicolonSeparatedList(value: string | undefined): string[] | undefined {
if (!value)
return undefined;
return value.split(';').map(v => v.trim());
}

export function commaSeparatedList(value: string | undefined): string[] | undefined {
if (!value)
return undefined;
Expand Down
27 changes: 27 additions & 0 deletions packages/playwright/src/mcp/browser/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,20 @@ export class Context {
Context._allContexts.delete(this);
}

private async _setupRequestInterception(context: playwright.BrowserContext) {
if (this.config.network?.allowedOrigins?.length) {
await context.route('**', route => route.abort('blockedbyclient'));

for (const origin of this.config.network.allowedOrigins)
await context.route(originOrHostGlob(origin), route => route.continue());
}

if (this.config.network?.blockedOrigins?.length) {
for (const origin of this.config.network.blockedOrigins)
await context.route(originOrHostGlob(origin), route => route.abort('blockedbyclient'));
}
}

async ensureBrowserContext(): Promise<playwright.BrowserContext> {
const { browserContext } = await this._ensureBrowserContext();
return browserContext;
Expand All @@ -226,6 +240,7 @@ export class Context {
selectors.setTestIdAttribute(this.config.testIdAttribute);
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal, this._runningToolName);
const { browserContext } = result;
await this._setupRequestInterception(browserContext);
if (this.sessionLog)
await InputRecorder.create(this, browserContext);
for (const page of browserContext.pages())
Expand All @@ -252,6 +267,18 @@ export class Context {
}
}

function originOrHostGlob(originOrHost: string) {
try {
const url = new URL(originOrHost);
// localhost:1234 will parse as protocol 'localhost:' and 'null' origin.
if (url.origin !== 'null')
return `${url.origin}/**`;
} catch {
}
// Support for legacy host-only mode.
return `*://${originOrHost}/**`;
}

export class InputRecorder {
private _context: Context;
private _browserContext: playwright.BrowserContext;
Expand Down
12 changes: 12 additions & 0 deletions packages/playwright/src/mcp/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,18 @@ export type Config = {
*/
outputDir?: string;

network?: {
/**
* List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
*/
allowedOrigins?: string[];

/**
* List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
*/
blockedOrigins?: string[];
};

/**
* Specify the attribute to use for test ids, defaults to "data-testid".
*/
Expand Down
4 changes: 3 additions & 1 deletion packages/playwright/src/mcp/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { colors, ProgramOption } from 'playwright-core/lib/utilsBundle';
import { registry } from 'playwright-core/lib/server';

import * as mcpServer from './sdk/server';
import { commaSeparatedList, dotenvFileLoader, headerParser, numberParser, resolutionParser, resolveCLIConfig } from './browser/config';
import { commaSeparatedList, dotenvFileLoader, headerParser, numberParser, resolutionParser, resolveCLIConfig, semicolonSeparatedList } from './browser/config';
import { setupExitWatchdog } from './browser/watchdog';
import { contextFactory } from './browser/browserContextFactory';
import { ProxyBackend } from './sdk/proxyBackend';
Expand All @@ -35,6 +35,8 @@ import type { MCPProvider } from './sdk/proxyBackend';
export function decorateCommand(command: Command, version: string) {
command
.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)
.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)
.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)
.option('--block-service-workers', 'block service workers')
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
.option('--caps <caps>', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf.', commaSeparatedList)
Expand Down
127 changes: 127 additions & 0 deletions tests/mcp/request-blocking.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { test, expect } from './fixtures';

const BLOCK_MESSAGE = /Blocked by Web Inspector|NS_ERROR_FAILURE|net::ERR_BLOCKED_BY_CLIENT/g;

const fetchPage = async (client: Client, url: string) => {
const result = await client.callTool({
name: 'browser_navigate',
arguments: {
url,
},
});

return JSON.stringify(result, null, 2);
};

test('default to allow all', async ({ server, client }) => {
server.setContent('/ppp', 'content:PPP', 'text/html');
const result = await fetchPage(client, server.PREFIX + '/ppp');
expect(result).toContain('content:PPP');
});

test('blocked works (hostname)', async ({ startClient }) => {
const { client } = await startClient({
args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev']
});
const result = await fetchPage(client, 'https://example.com/');
expect(result).toMatch(BLOCK_MESSAGE);
});

test('blocked works (origin)', async ({ startClient }) => {
const { client } = await startClient({
args: ['--blocked-origins', 'https://microsoft.com;https://example.com;https://playwright.dev']
});
const result = await fetchPage(client, 'https://example.com/');
expect(result).toMatch(BLOCK_MESSAGE);
});

test('allowed works (hostname)', async ({ server, startClient }) => {
server.setContent('/ppp', 'content:PPP', 'text/html');
const { client } = await startClient({
args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
});
const result = await fetchPage(client, server.PREFIX + '/ppp');
expect(result).toContain('content:PPP');
});

test('allowed works (origin)', async ({ server, startClient }) => {
server.setContent('/ppp', 'content:PPP', 'text/html');
const { client } = await startClient({
args: ['--allowed-origins', `https://microsoft.com;${new URL(server.PREFIX).origin};https://playwright.dev`]
});
const result = await fetchPage(client, server.PREFIX + '/ppp');
expect(result).toContain('content:PPP');
});

test('blocked takes precedence (hostname)', async ({ startClient }) => {
const { client } = await startClient({
args: [
'--blocked-origins', 'example.com',
'--allowed-origins', 'example.com',
],
});
const result = await fetchPage(client, 'https://example.com/');
expect(result).toMatch(BLOCK_MESSAGE);
});

test('blocked takes precedence (origin)', async ({ startClient }) => {
const { client } = await startClient({
args: [
'--blocked-origins', 'https://example.com',
'--allowed-origins', 'https://example.com',
],
});
const result = await fetchPage(client, 'https://example.com/');
expect(result).toMatch(BLOCK_MESSAGE);
});

test('allowed without blocked blocks all non-explicitly specified origins (hostname)', async ({ startClient }) => {
const { client } = await startClient({
args: ['--allowed-origins', 'playwright.dev'],
});
const result = await fetchPage(client, 'https://example.com/');
expect(result).toMatch(BLOCK_MESSAGE);
});

test('allowed without blocked blocks all non-explicitly specified origins (origin)', async ({ startClient }) => {
const { client } = await startClient({
args: ['--allowed-origins', 'https://playwright.dev'],
});
const result = await fetchPage(client, 'https://example.com/');
expect(result).toMatch(BLOCK_MESSAGE);
});

test('blocked without allowed allows non-explicitly specified origins (hostname)', async ({ server, startClient }) => {
server.setContent('/ppp', 'content:PPP', 'text/html');
const { client } = await startClient({
args: ['--blocked-origins', 'example.com'],
});
const result = await fetchPage(client, server.PREFIX + '/ppp');
expect(result).toContain('content:PPP');
});

test('blocked without allowed allows non-explicitly specified origins (origin)', async ({ server, startClient }) => {
server.setContent('/ppp', 'content:PPP', 'text/html');
const { client } = await startClient({
args: ['--blocked-origins', 'https://example.com'],
});
const result = await fetchPage(client, server.PREFIX + '/ppp');
expect(result).toContain('content:PPP');
});
Loading