Skip to content

Commit 1df01db

Browse files
priyanshu92Copilot
andauthored
Harden Power Pages Playwright MCP root resolution (#382)
* Harden Playwright MCP root resolution - require an absolute host-provided plugin root\n- canonicalize and contain the launcher path\n- cover invalid roots, malicious cwd, and supported hosts\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * address PR review feedback - Guard EACCES/EPERM errors while statting the declared plugin root. - Guard EACCES/EPERM errors while statting the resolved launcher. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7e799af6-1da3-4859-931b-4f199e3e1146 * address PR review feedback - Classify an exact-parent launcher resolution as escaping the declared plugin root. - Add deterministic coverage for the exact-parent containment boundary. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7e799af6-1da3-4859-931b-4f199e3e1146 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7e799af6-1da3-4859-931b-4f199e3e1146
1 parent 965ce05 commit 1df01db

3 files changed

Lines changed: 230 additions & 17 deletions

File tree

plugins/power-pages/.mcp.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"command": "node",
55
"args": [
66
"-e",
7-
"const fs=require('node:fs'); const path=require('node:path'); const root=process.env.PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT||process.cwd(); const entry=path.resolve(root,'scripts','launch-playwright-mcp.js'); if(!fs.existsSync(entry)) throw new Error('Could not resolve Power Pages plugin root; set PLUGIN_ROOT or launch from the plugin root'); const mod=require(entry); if(!mod||typeof mod.launch!=='function') throw new Error('Power Pages Playwright MCP launcher did not export launch()'); mod.launch();"
7+
"const fs=require('node:fs'); const path=require('node:path'); const fail=(message)=>{throw new Error('[Power Pages Playwright MCP] '+message);}; const declaredRoot=process.env.PLUGIN_ROOT||process.env.CLAUDE_PLUGIN_ROOT; if(!declaredRoot) fail('PLUGIN_ROOT or CLAUDE_PLUGIN_ROOT must be set; refusing to resolve the launcher from the current working directory.'); if(!path.isAbsolute(declaredRoot)) fail('Declared plugin root must be an absolute path: '+declaredRoot+'.'); let root; try{root=fs.realpathSync(declaredRoot);}catch(error){fail('Declared plugin root is invalid: '+declaredRoot+' ('+(error.code||error.message)+').');} let rootStat; try{rootStat=fs.statSync(root);}catch(error){fail('Could not inspect declared plugin root: '+root+' ('+(error.code||error.message)+').');} if(!rootStat.isDirectory()) fail('Declared plugin root is not a directory: '+declaredRoot+'.'); const candidate=path.resolve(root,'scripts','launch-playwright-mcp.js'); let entry; try{entry=fs.realpathSync(candidate);}catch(error){fail('Launcher was not found under the declared plugin root: '+candidate+'.');} const relative=path.relative(root,entry); if(relative==='..'||relative.startsWith('..'+path.sep)||path.isAbsolute(relative)) fail('Resolved launcher escapes the declared plugin root: '+entry+'.'); let entryStat; try{entryStat=fs.statSync(entry);}catch(error){fail('Could not inspect resolved launcher: '+entry+' ('+(error.code||error.message)+').');} if(!entryStat.isFile()) fail('Resolved launcher is not a file: '+entry+'.'); const mod=require(entry); if(!mod||typeof mod.launch!=='function') fail('Launcher did not export launch(): '+entry+'.'); mod.launch();"
88
]
99
},
1010
"microsoft-learn": {

plugins/power-pages/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,8 @@ The plugin ships with two MCP servers configured in `.mcp.json` — they start a
436436
| **playwright** | Headless browser automation for live previews and runtime tests |
437437
| **microsoft-learn** | Grounded search/fetch over official Microsoft Learn docs |
438438

439+
The plugin host must provide an absolute `PLUGIN_ROOT` (GitHub Copilot) or `CLAUDE_PLUGIN_ROOT` (Claude Code). The Playwright bootstrap resolves its launcher only from that declared plugin root and never from the workspace working directory.
440+
439441
## Typical Workflow
440442

441443
A common end-to-end workflow looks like this:

plugins/power-pages/scripts/tests/mcp-config.test.js

Lines changed: 227 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ const { spawnSync } = require('node:child_process');
66
const test = require('node:test');
77

88
const pluginRoot = path.resolve(__dirname, '..', '..');
9+
const config = JSON.parse(fs.readFileSync(path.join(pluginRoot, '.mcp.json'), 'utf8'));
10+
const server = config.mcpServers.playwright;
911

1012
function createFakeNpx(dir) {
1113
const commandPath = path.join(dir, process.platform === 'win32' ? 'npx.cmd' : 'npx');
@@ -17,27 +19,236 @@ function createFakeNpx(dir) {
1719
return commandPath;
1820
}
1921

20-
test('playwright MCP bootstrap resolves the plugin root without host-provided env vars', (t) => {
22+
function runBootstrap({
23+
cwd,
24+
pluginRoot: pluginRootValue,
25+
claudePluginRoot,
26+
pathPrefix,
27+
realpathOverride,
28+
statFailure,
29+
} = {}) {
30+
const env = { ...process.env };
31+
const args = [...server.args];
32+
delete env.PLUGIN_ROOT;
33+
delete env.CLAUDE_PLUGIN_ROOT;
34+
35+
if (pluginRootValue !== undefined) {
36+
env.PLUGIN_ROOT = pluginRootValue;
37+
}
38+
if (claudePluginRoot !== undefined) {
39+
env.CLAUDE_PLUGIN_ROOT = claudePluginRoot;
40+
}
41+
if (pathPrefix) {
42+
const pathSeparator = process.platform === 'win32' ? ';' : ':';
43+
env.PATH = `${pathPrefix}${pathSeparator}${env.PATH || ''}`;
44+
}
45+
if (realpathOverride) {
46+
// Patch only the launcher lookup so the canonical root still follows the real filesystem.
47+
const bootstrapIndex = args.indexOf('-e') + 1;
48+
const prelude = [
49+
"const injectedFs=require('node:fs');",
50+
'const originalRealpathSync=injectedFs.realpathSync;',
51+
`const injectedRealpathTarget=${JSON.stringify(path.resolve(realpathOverride.target))};`,
52+
`const injectedRealpathResult=${JSON.stringify(path.resolve(realpathOverride.result))};`,
53+
"injectedFs.realpathSync=function(target,...options){if(require('node:path').resolve(String(target))===injectedRealpathTarget)return injectedRealpathResult;return originalRealpathSync.call(this,target,...options);};",
54+
].join(' ');
55+
args[bootstrapIndex] = `${prelude} ${args[bootstrapIndex]}`;
56+
}
57+
if (statFailure) {
58+
// Patch the child process's fs module so access errors are deterministic across platforms.
59+
const bootstrapIndex = args.indexOf('-e') + 1;
60+
const prelude = [
61+
"const injectedFs=require('node:fs');",
62+
'const originalStatSync=injectedFs.statSync;',
63+
`const injectedStatTarget=${JSON.stringify(path.resolve(statFailure.target))};`,
64+
`const injectedStatCode=${JSON.stringify(statFailure.code)};`,
65+
"injectedFs.statSync=function(target,...options){if(require('node:path').resolve(String(target))===injectedStatTarget){const error=new Error('injected statSync failure');error.code=injectedStatCode;throw error;}return originalStatSync.call(this,target,...options);};",
66+
].join(' ');
67+
args[bootstrapIndex] = `${prelude} ${args[bootstrapIndex]}`;
68+
}
69+
70+
return spawnSync(server.command, args, {
71+
cwd,
72+
encoding: 'utf8',
73+
env,
74+
timeout: 5_000,
75+
});
76+
}
77+
78+
test('playwright MCP bootstrap wraps root stat errors with a clear diagnostic', () => {
79+
const root = fs.realpathSync(pluginRoot);
80+
const result = runBootstrap({
81+
cwd: pluginRoot,
82+
pluginRoot,
83+
statFailure: { target: root, code: 'EACCES' },
84+
});
85+
86+
assert.notEqual(result.status, 0);
87+
assert.match(
88+
result.stderr,
89+
/\[Power Pages Playwright MCP\] Could not inspect declared plugin root: .+ \(EACCES\)\./,
90+
);
91+
});
92+
93+
test('playwright MCP bootstrap wraps launcher stat errors with a clear diagnostic', () => {
94+
const launcher = fs.realpathSync(path.join(pluginRoot, 'scripts', 'launch-playwright-mcp.js'));
95+
const result = runBootstrap({
96+
cwd: pluginRoot,
97+
pluginRoot,
98+
statFailure: { target: launcher, code: 'EPERM' },
99+
});
100+
101+
assert.notEqual(result.status, 0);
102+
assert.match(
103+
result.stderr,
104+
/\[Power Pages Playwright MCP\] Could not inspect resolved launcher: .+ \(EPERM\)\./,
105+
);
106+
});
107+
108+
test('playwright MCP bootstrap requires a host-provided plugin root', () => {
109+
const result = runBootstrap({ cwd: pluginRoot });
110+
111+
assert.notEqual(result.status, 0);
112+
assert.match(result.stderr, /PLUGIN_ROOT or CLAUDE_PLUGIN_ROOT must be set/);
113+
assert.match(result.stderr, /refusing to resolve the launcher from the current working directory/);
114+
});
115+
116+
test('playwright MCP bootstrap does not execute a launcher from a malicious cwd', (t) => {
117+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'power-pages-mcp-malicious-cwd-'));
118+
const scriptsDir = path.join(tempDir, 'scripts');
119+
const markerPath = path.join(tempDir, 'cwd-launcher-executed');
120+
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
121+
122+
fs.mkdirSync(scriptsDir);
123+
fs.writeFileSync(
124+
path.join(scriptsDir, 'launch-playwright-mcp.js'),
125+
`require('node:fs').writeFileSync(${JSON.stringify(markerPath)}, 'executed'); module.exports = { launch() {} };\n`,
126+
);
127+
128+
const result = runBootstrap({ cwd: tempDir });
129+
130+
assert.notEqual(result.status, 0);
131+
assert.match(result.stderr, /PLUGIN_ROOT or CLAUDE_PLUGIN_ROOT must be set/);
132+
assert.equal(fs.existsSync(markerPath), false);
133+
});
134+
135+
test('playwright MCP bootstrap rejects malformed plugin roots', async (t) => {
136+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'power-pages-mcp-invalid-root-'));
137+
const fileRoot = path.join(tempDir, 'not-a-directory');
138+
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
139+
fs.writeFileSync(fileRoot, 'not a plugin root');
140+
141+
await t.test('nonexistent root', () => {
142+
const result = runBootstrap({
143+
cwd: tempDir,
144+
pluginRoot: path.join(tempDir, 'missing'),
145+
});
146+
147+
assert.notEqual(result.status, 0);
148+
assert.match(result.stderr, /Declared plugin root is invalid/);
149+
});
150+
151+
await t.test('file root', () => {
152+
const result = runBootstrap({ cwd: tempDir, pluginRoot: fileRoot });
153+
154+
assert.notEqual(result.status, 0);
155+
assert.match(result.stderr, /Declared plugin root is not a directory/);
156+
});
157+
158+
await t.test('relative root', () => {
159+
const result = runBootstrap({ cwd: tempDir, pluginRoot: '.' });
160+
161+
assert.notEqual(result.status, 0);
162+
assert.match(result.stderr, /Declared plugin root must be an absolute path/);
163+
});
164+
});
165+
166+
test('playwright MCP bootstrap rejects a launcher that resolves outside the plugin root', (t) => {
167+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'power-pages-mcp-escape-'));
168+
const declaredRoot = path.join(tempDir, 'plugin');
169+
const outsideScripts = path.join(tempDir, 'outside-scripts');
170+
const markerPath = path.join(tempDir, 'escaped-launcher-executed');
171+
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
172+
173+
fs.mkdirSync(declaredRoot);
174+
fs.mkdirSync(outsideScripts);
175+
fs.writeFileSync(
176+
path.join(outsideScripts, 'launch-playwright-mcp.js'),
177+
`require('node:fs').writeFileSync(${JSON.stringify(markerPath)}, 'executed'); module.exports = { launch() {} };\n`,
178+
);
179+
180+
try {
181+
fs.symlinkSync(
182+
outsideScripts,
183+
path.join(declaredRoot, 'scripts'),
184+
process.platform === 'win32' ? 'junction' : 'dir',
185+
);
186+
} catch (error) {
187+
if (error.code === 'EPERM' || error.code === 'EACCES') {
188+
t.skip(`symlinks are unavailable: ${error.code}`);
189+
return;
190+
}
191+
throw error;
192+
}
193+
194+
const result = runBootstrap({ cwd: tempDir, pluginRoot: declaredRoot });
195+
196+
assert.notEqual(result.status, 0);
197+
assert.match(result.stderr, /Resolved launcher escapes the declared plugin root/);
198+
assert.equal(fs.existsSync(markerPath), false);
199+
});
200+
201+
test('playwright MCP bootstrap rejects a launcher resolving to the exact parent directory', (t) => {
202+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'power-pages-mcp-parent-escape-'));
203+
const declaredRoot = path.join(tempDir, 'plugin');
204+
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
205+
fs.mkdirSync(declaredRoot);
206+
const canonicalRoot = fs.realpathSync(declaredRoot);
207+
const candidate = path.join(canonicalRoot, 'scripts', 'launch-playwright-mcp.js');
208+
const exactParent = path.dirname(canonicalRoot);
209+
210+
const result = runBootstrap({
211+
cwd: tempDir,
212+
pluginRoot: declaredRoot,
213+
realpathOverride: { target: candidate, result: exactParent },
214+
});
215+
216+
assert.notEqual(result.status, 0);
217+
assert.match(
218+
result.stderr,
219+
/Error: \[Power Pages Playwright MCP\] Resolved launcher escapes the declared plugin root:/,
220+
);
221+
assert.doesNotMatch(
222+
result.stderr,
223+
/Error: \[Power Pages Playwright MCP\] Resolved launcher is not a file:/,
224+
);
225+
});
226+
227+
test('playwright MCP bootstrap supports installed-plugin root environment conventions', async (t) => {
21228
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'power-pages-mcp-'));
22229
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
23230

24231
createFakeNpx(tempDir);
25232

26-
const config = JSON.parse(fs.readFileSync(path.join(pluginRoot, '.mcp.json'), 'utf8'));
27-
const server = config.mcpServers.playwright;
28-
const pathSeparator = process.platform === 'win32' ? ';' : ':';
29-
const result = spawnSync(server.command, server.args, {
30-
cwd: pluginRoot,
31-
encoding: 'utf8',
32-
env: {
33-
HOME: process.env.HOME,
34-
PATH: `${tempDir}${pathSeparator}${process.env.PATH || ''}`,
35-
USERPROFILE: process.env.USERPROFILE,
36-
},
37-
timeout: 5_000,
233+
await t.test('PLUGIN_ROOT', () => {
234+
const result = runBootstrap({
235+
cwd: tempDir,
236+
pluginRoot,
237+
pathPrefix: tempDir,
238+
});
239+
240+
assert.equal(result.status, 0, result.stderr);
241+
assert.match(result.stdout, /fake-npx/);
38242
});
39243

40-
assert.equal(result.status, 0, result.stderr);
41-
assert.match(result.stdout, /fake-npx/);
42-
assert.doesNotMatch(result.stderr, /PLUGIN_ROOT is not set/);
244+
await t.test('CLAUDE_PLUGIN_ROOT', () => {
245+
const result = runBootstrap({
246+
cwd: tempDir,
247+
claudePluginRoot: pluginRoot,
248+
pathPrefix: tempDir,
249+
});
250+
251+
assert.equal(result.status, 0, result.stderr);
252+
assert.match(result.stdout, /fake-npx/);
253+
});
43254
});

0 commit comments

Comments
 (0)