Ross Wollman commited on
Commit
42faa3c
·
unverified ·
1 Parent(s): 4694d60

feat: add --(allowed|blocked)-origins (#319)

Browse files

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.

Files changed (6) hide show
  1. README.md +11 -0
  2. config.d.ts +12 -0
  3. src/config.ts +14 -0
  4. src/context.ts +15 -0
  5. src/program.ts +6 -0
  6. tests/request-blocking.spec.ts +91 -0
README.md CHANGED
@@ -76,6 +76,8 @@ The Playwright MCP server supports the following command-line options:
76
  - `--user-data-dir <path>`: Path to the user data directory
77
  - `--port <port>`: Port to listen on for SSE transport
78
  - `--host <host>`: Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
 
 
79
  - `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
80
  - `--output-dir`: Directory for output files
81
  - `--config <path>`: Path to the configuration file
@@ -153,6 +155,15 @@ The Playwright MCP server can be configured using a JSON configuration file. Her
153
  // Directory for output files
154
  outputDir?: string;
155
 
 
 
 
 
 
 
 
 
 
156
  // Tool-specific configurations
157
  tools?: {
158
  browser_take_screenshot?: {
 
76
  - `--user-data-dir <path>`: Path to the user data directory
77
  - `--port <port>`: Port to listen on for SSE transport
78
  - `--host <host>`: Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
79
+ - `--allowed-origins <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.
80
+ - `--blocked-origins <origins>`: Semicolon-separated list of origins to block the browser to request. Origins matching both `--allowed-origins` and `--blocked-origins` will be blocked.
81
  - `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
82
  - `--output-dir`: Directory for output files
83
  - `--config <path>`: Path to the configuration file
 
155
  // Directory for output files
156
  outputDir?: string;
157
 
158
+ // Network configuration
159
+ network?: {
160
+ // List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
161
+ allowedOrigins?: string[];
162
+
163
+ // List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
164
+ blockedOrigins?: string[];
165
+ };
166
+
167
  // Tool-specific configurations
168
  tools?: {
169
  browser_take_screenshot?: {
config.d.ts CHANGED
@@ -94,6 +94,18 @@ export type Config = {
94
  */
95
  outputDir?: string;
96
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  /**
98
  * Configuration for specific tools.
99
  */
 
94
  */
95
  outputDir?: string;
96
 
97
+ network?: {
98
+ /**
99
+ * List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
100
+ */
101
+ allowedOrigins?: string[];
102
+
103
+ /**
104
+ * List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
105
+ */
106
+ blockedOrigins?: string[];
107
+ };
108
+
109
  /**
110
  * Configuration for specific tools.
111
  */
src/config.ts CHANGED
@@ -36,6 +36,8 @@ export type CLIOptions = {
36
  host?: string;
37
  vision?: boolean;
38
  config?: string;
 
 
39
  outputDir?: string;
40
  };
41
 
@@ -50,6 +52,10 @@ const defaultConfig: Config = {
50
  viewport: null,
51
  },
52
  },
 
 
 
 
53
  };
54
 
55
  export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> {
@@ -110,6 +116,10 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
110
  },
111
  capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
112
  vision: !!cliOptions.vision,
 
 
 
 
113
  outputDir: cliOptions.outputDir,
114
  };
115
  }
@@ -171,5 +181,9 @@ function mergeConfig(base: Config, overrides: Config): Config {
171
  ...pickDefined(base),
172
  ...pickDefined(overrides),
173
  browser,
 
 
 
 
174
  };
175
  }
 
36
  host?: string;
37
  vision?: boolean;
38
  config?: string;
39
+ allowedOrigins?: string[];
40
+ blockedOrigins?: string[];
41
  outputDir?: string;
42
  };
43
 
 
52
  viewport: null,
53
  },
54
  },
55
+ network: {
56
+ allowedOrigins: undefined,
57
+ blockedOrigins: undefined,
58
+ },
59
  };
60
 
61
  export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> {
 
116
  },
117
  capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
118
  vision: !!cliOptions.vision,
119
+ network: {
120
+ allowedOrigins: cliOptions.allowedOrigins,
121
+ blockedOrigins: cliOptions.blockedOrigins,
122
+ },
123
  outputDir: cliOptions.outputDir,
124
  };
125
  }
 
181
  ...pickDefined(base),
182
  ...pickDefined(overrides),
183
  browser,
184
+ network: {
185
+ ...pickDefined(base.network),
186
+ ...pickDefined(overrides.network),
187
+ },
188
  };
189
  }
src/context.ts CHANGED
@@ -290,11 +290,26 @@ ${code.join('\n')}
290
  }).catch(() => {});
291
  }
292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  private async _ensureBrowserContext() {
294
  if (!this._browserContext) {
295
  const context = await this._createBrowserContext();
296
  this._browser = context.browser;
297
  this._browserContext = context.browserContext;
 
298
  for (const page of this._browserContext.pages())
299
  this._onPageCreated(page);
300
  this._browserContext.on('page', page => this._onPageCreated(page));
 
290
  }).catch(() => {});
291
  }
292
 
293
+ private async _setupRequestInterception(context: playwright.BrowserContext) {
294
+ if (this.config.network?.allowedOrigins?.length) {
295
+ await context.route('**', route => route.abort('blockedbyclient'));
296
+
297
+ for (const origin of this.config.network.allowedOrigins)
298
+ await context.route(`*://${origin}/**`, route => route.continue());
299
+ }
300
+
301
+ if (this.config.network?.blockedOrigins?.length) {
302
+ for (const origin of this.config.network.blockedOrigins)
303
+ await context.route(`*://${origin}/**`, route => route.abort('blockedbyclient'));
304
+ }
305
+ }
306
+
307
  private async _ensureBrowserContext() {
308
  if (!this._browserContext) {
309
  const context = await this._createBrowserContext();
310
  this._browser = context.browser;
311
  this._browserContext = context.browserContext;
312
+ await this._setupRequestInterception(this._browserContext);
313
  for (const page of this._browserContext.pages())
314
  this._onPageCreated(page);
315
  this._browserContext.on('page', page => this._onPageCreated(page));
src/program.ts CHANGED
@@ -37,6 +37,8 @@ program
37
  .option('--user-data-dir <path>', 'Path to the user data directory')
38
  .option('--port <port>', 'Port to listen on for SSE transport.')
39
  .option('--host <host>', 'Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
 
 
40
  .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
41
  .option('--output-dir <path>', 'Path to the directory for output files.')
42
  .option('--config <path>', 'Path to the configuration file.')
@@ -63,4 +65,8 @@ function setupExitWatchdog(serverList: ServerList) {
63
  process.on('SIGTERM', handleExit);
64
  }
65
 
 
 
 
 
66
  program.parse(process.argv);
 
37
  .option('--user-data-dir <path>', 'Path to the user data directory')
38
  .option('--port <port>', 'Port to listen on for SSE transport.')
39
  .option('--host <host>', 'Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
40
+ .option('--allowed-origins <origins>', 'Semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList)
41
+ .option('--blocked-origins <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)
42
  .option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
43
  .option('--output-dir <path>', 'Path to the directory for output files.')
44
  .option('--config <path>', 'Path to the configuration file.')
 
65
  process.on('SIGTERM', handleExit);
66
  }
67
 
68
+ function semicolonSeparatedList(value: string): string[] {
69
+ return value.split(';').map(v => v.trim());
70
+ }
71
+
72
  program.parse(process.argv);
tests/request-blocking.spec.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.ts';
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.route('/ppp', (_req, res) => {
35
+ res.writeHead(200, { 'Content-Type': 'text/html' });
36
+ res.end('content:PPP');
37
+ });
38
+ const result = await fetchPage(client, server.PREFIX + '/ppp');
39
+ expect(result).toContain('content:PPP');
40
+ });
41
+
42
+ test('blocked works', async ({ startClient }) => {
43
+ const client = await startClient({
44
+ args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev']
45
+ });
46
+ const result = await fetchPage(client, 'https://example.com/');
47
+ expect(result).toMatch(BLOCK_MESSAGE);
48
+ });
49
+
50
+ test('allowed works', async ({ server, startClient }) => {
51
+ server.route('/ppp', (_req, res) => {
52
+ res.writeHead(200, { 'Content-Type': 'text/html' });
53
+ res.end('content:PPP');
54
+ });
55
+ const client = await startClient({
56
+ args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
57
+ });
58
+ const result = await fetchPage(client, server.PREFIX + '/ppp');
59
+ expect(result).toContain('content:PPP');
60
+ });
61
+
62
+ test('blocked takes precedence', async ({ startClient }) => {
63
+ const client = await startClient({
64
+ args: [
65
+ '--blocked-origins', 'example.com',
66
+ '--allowed-origins', 'example.com',
67
+ ],
68
+ });
69
+ const result = await fetchPage(client, 'https://example.com/');
70
+ expect(result).toMatch(BLOCK_MESSAGE);
71
+ });
72
+
73
+ test('allowed without blocked blocks all non-explicitly specified origins', async ({ startClient }) => {
74
+ const client = await startClient({
75
+ args: ['--allowed-origins', 'playwright.dev'],
76
+ });
77
+ const result = await fetchPage(client, 'https://example.com/');
78
+ expect(result).toMatch(BLOCK_MESSAGE);
79
+ });
80
+
81
+ test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => {
82
+ server.route('/ppp', (_req, res) => {
83
+ res.writeHead(200, { 'Content-Type': 'text/html' });
84
+ res.end('content:PPP');
85
+ });
86
+ const client = await startClient({
87
+ args: ['--blocked-origins', 'example.com'],
88
+ });
89
+ const result = await fetchPage(client, server.PREFIX + '/ppp');
90
+ expect(result).toContain('content:PPP');
91
+ });