From 42faa3ccf84bb3a5ecf2d5fb210eafb94bd3e76a Mon Sep 17 00:00:00 2001 From: Ross Wollman Date: Mon, 5 May 2025 11:28:14 -0700 Subject: [PATCH] feat: add --(allowed|blocked)-origins (#319) Useful to limit the agent when using the playwright-mcp server with an agent in auto-invocation mode. Not intended to be a security feature. --- README.md | 11 ++++ config.d.ts | 12 +++++ src/config.ts | 14 ++++++ src/context.ts | 15 ++++++ src/program.ts | 6 +++ tests/request-blocking.spec.ts | 91 ++++++++++++++++++++++++++++++++++ 6 files changed, 149 insertions(+) create mode 100644 tests/request-blocking.spec.ts diff --git a/README.md b/README.md index 9b84272..462fd43 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ The Playwright MCP server supports the following command-line options: - `--user-data-dir `: Path to the user data directory - `--port `: Port to listen on for SSE transport - `--host `: Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces. +- `--allowed-origins `: Semicolon-separated list of origins to allow the browser to request. Default is to allow all. Origins matching both `--allowed-origins` and `--blocked-origins` will be blocked. +- `--blocked-origins `: Semicolon-separated list of origins to block the browser to request. Origins matching both `--allowed-origins` and `--blocked-origins` will be blocked. - `--vision`: Run server that uses screenshots (Aria snapshots are used by default) - `--output-dir`: Directory for output files - `--config `: Path to the configuration file @@ -153,6 +155,15 @@ The Playwright MCP server can be configured using a JSON configuration file. Her // Directory for output files outputDir?: string; + // Network configuration + 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[]; + }; + // Tool-specific configurations tools?: { browser_take_screenshot?: { diff --git a/config.d.ts b/config.d.ts index 65ebec3..053c969 100644 --- a/config.d.ts +++ b/config.d.ts @@ -94,6 +94,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[]; + }; + /** * Configuration for specific tools. */ diff --git a/src/config.ts b/src/config.ts index de1da56..99820d6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,6 +36,8 @@ export type CLIOptions = { host?: string; vision?: boolean; config?: string; + allowedOrigins?: string[]; + blockedOrigins?: string[]; outputDir?: string; }; @@ -50,6 +52,10 @@ const defaultConfig: Config = { viewport: null, }, }, + network: { + allowedOrigins: undefined, + blockedOrigins: undefined, + }, }; export async function resolveConfig(cliOptions: CLIOptions): Promise { @@ -110,6 +116,10 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise c.trim() as ToolCapability), vision: !!cliOptions.vision, + network: { + allowedOrigins: cliOptions.allowedOrigins, + blockedOrigins: cliOptions.blockedOrigins, + }, outputDir: cliOptions.outputDir, }; } @@ -171,5 +181,9 @@ function mergeConfig(base: Config, overrides: Config): Config { ...pickDefined(base), ...pickDefined(overrides), browser, + network: { + ...pickDefined(base.network), + ...pickDefined(overrides.network), + }, }; } diff --git a/src/context.ts b/src/context.ts index 236015b..a179401 100644 --- a/src/context.ts +++ b/src/context.ts @@ -290,11 +290,26 @@ ${code.join('\n')} }).catch(() => {}); } + 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(`*://${origin}/**`, route => route.continue()); + } + + if (this.config.network?.blockedOrigins?.length) { + for (const origin of this.config.network.blockedOrigins) + await context.route(`*://${origin}/**`, route => route.abort('blockedbyclient')); + } + } + private async _ensureBrowserContext() { if (!this._browserContext) { const context = await this._createBrowserContext(); this._browser = context.browser; this._browserContext = context.browserContext; + await this._setupRequestInterception(this._browserContext); for (const page of this._browserContext.pages()) this._onPageCreated(page); this._browserContext.on('page', page => this._onPageCreated(page)); diff --git a/src/program.ts b/src/program.ts index 660749e..1753490 100644 --- a/src/program.ts +++ b/src/program.ts @@ -37,6 +37,8 @@ program .option('--user-data-dir ', 'Path to the user data directory') .option('--port ', 'Port to listen on for SSE transport.') .option('--host ', 'Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.') + .option('--allowed-origins ', 'Semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList) + .option('--blocked-origins ', 'Semicolon-separated list of 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.', semicolonSeparatedList) .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)') .option('--output-dir ', 'Path to the directory for output files.') .option('--config ', 'Path to the configuration file.') @@ -63,4 +65,8 @@ function setupExitWatchdog(serverList: ServerList) { process.on('SIGTERM', handleExit); } +function semicolonSeparatedList(value: string): string[] { + return value.split(';').map(v => v.trim()); +} + program.parse(process.argv); diff --git a/tests/request-blocking.spec.ts b/tests/request-blocking.spec.ts new file mode 100644 index 0000000..c65cbe5 --- /dev/null +++ b/tests/request-blocking.spec.ts @@ -0,0 +1,91 @@ +/** + * 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.ts'; + +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.route('/ppp', (_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('content:PPP'); + }); + const result = await fetchPage(client, server.PREFIX + '/ppp'); + expect(result).toContain('content:PPP'); +}); + +test('blocked works', 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('allowed works', async ({ server, startClient }) => { + server.route('/ppp', (_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('content:PPP'); + }); + 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('blocked takes precedence', 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('allowed without blocked blocks all non-explicitly specified origins', 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('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => { + server.route('/ppp', (_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('content:PPP'); + }); + const client = await startClient({ + args: ['--blocked-origins', 'example.com'], + }); + const result = await fetchPage(client, server.PREFIX + '/ppp'); + expect(result).toContain('content:PPP'); +});