Skip to content

Commit fed5f20

Browse files
committed
Merge final shell security parent
2 parents 2b20e47 + 48afdd2 commit fed5f20

2 files changed

Lines changed: 93 additions & 5 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 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)+').');} if(!fs.statSync(root).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.startsWith('..'+path.sep)||path.isAbsolute(relative)) fail('Resolved launcher escapes the declared plugin root: '+entry+'.'); if(!fs.statSync(entry).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();"
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/scripts/tests/mcp-config.test.js

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,16 @@ require('node:child_process').spawn = (command, args, options) => {
3232
return preloadPath;
3333
}
3434

35-
function runBootstrap({ cwd, pluginRoot: pluginRootValue, claudePluginRoot, preloadPath } = {}) {
35+
function runBootstrap({
36+
cwd,
37+
pluginRoot: pluginRootValue,
38+
claudePluginRoot,
39+
preloadPath,
40+
realpathOverride,
41+
statFailure,
42+
} = {}) {
3643
const env = { ...process.env };
44+
let args = [...server.args];
3745
delete env.PLUGIN_ROOT;
3846
delete env.CLAUDE_PLUGIN_ROOT;
3947

@@ -43,10 +51,34 @@ function runBootstrap({ cwd, pluginRoot: pluginRootValue, claudePluginRoot, prel
4351
if (claudePluginRoot !== undefined) {
4452
env.CLAUDE_PLUGIN_ROOT = claudePluginRoot;
4553
}
54+
if (realpathOverride) {
55+
// Patch only the launcher lookup so the canonical root still follows the real filesystem.
56+
const bootstrapIndex = args.indexOf('-e') + 1;
57+
const prelude = [
58+
"const injectedFs=require('node:fs');",
59+
'const originalRealpathSync=injectedFs.realpathSync;',
60+
`const injectedRealpathTarget=${JSON.stringify(path.resolve(realpathOverride.target))};`,
61+
`const injectedRealpathResult=${JSON.stringify(path.resolve(realpathOverride.result))};`,
62+
"injectedFs.realpathSync=function(target,...options){if(require('node:path').resolve(String(target))===injectedRealpathTarget)return injectedRealpathResult;return originalRealpathSync.call(this,target,...options);};",
63+
].join(' ');
64+
args[bootstrapIndex] = `${prelude} ${args[bootstrapIndex]}`;
65+
}
66+
if (statFailure) {
67+
// Patch the child process's fs module so access errors are deterministic across platforms.
68+
const bootstrapIndex = args.indexOf('-e') + 1;
69+
const prelude = [
70+
"const injectedFs=require('node:fs');",
71+
'const originalStatSync=injectedFs.statSync;',
72+
`const injectedStatTarget=${JSON.stringify(path.resolve(statFailure.target))};`,
73+
`const injectedStatCode=${JSON.stringify(statFailure.code)};`,
74+
"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);};",
75+
].join(' ');
76+
args[bootstrapIndex] = `${prelude} ${args[bootstrapIndex]}`;
77+
}
4678

47-
const args = preloadPath
48-
? ['--require', preloadPath, ...server.args]
49-
: server.args;
79+
if (preloadPath) {
80+
args = ['--require', preloadPath, ...args];
81+
}
5082

5183
return spawnSync(server.command, args, {
5284
cwd,
@@ -56,6 +88,36 @@ function runBootstrap({ cwd, pluginRoot: pluginRootValue, claudePluginRoot, prel
5688
});
5789
}
5890

91+
test('playwright MCP bootstrap wraps root stat errors with a clear diagnostic', () => {
92+
const root = fs.realpathSync(pluginRoot);
93+
const result = runBootstrap({
94+
cwd: pluginRoot,
95+
pluginRoot,
96+
statFailure: { target: root, code: 'EACCES' },
97+
});
98+
99+
assert.notEqual(result.status, 0);
100+
assert.match(
101+
result.stderr,
102+
/\[Power Pages Playwright MCP\] Could not inspect declared plugin root: .+ \(EACCES\)\./,
103+
);
104+
});
105+
106+
test('playwright MCP bootstrap wraps launcher stat errors with a clear diagnostic', () => {
107+
const launcher = fs.realpathSync(path.join(pluginRoot, 'scripts', 'launch-playwright-mcp.js'));
108+
const result = runBootstrap({
109+
cwd: pluginRoot,
110+
pluginRoot,
111+
statFailure: { target: launcher, code: 'EPERM' },
112+
});
113+
114+
assert.notEqual(result.status, 0);
115+
assert.match(
116+
result.stderr,
117+
/\[Power Pages Playwright MCP\] Could not inspect resolved launcher: .+ \(EPERM\)\./,
118+
);
119+
});
120+
59121
test('playwright MCP bootstrap requires a host-provided plugin root', () => {
60122
const result = runBootstrap({ cwd: pluginRoot });
61123

@@ -149,6 +211,32 @@ test('playwright MCP bootstrap rejects a launcher that resolves outside the plug
149211
assert.equal(fs.existsSync(markerPath), false);
150212
});
151213

214+
test('playwright MCP bootstrap rejects a launcher resolving to the exact parent directory', (t) => {
215+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'power-pages-mcp-parent-escape-'));
216+
const declaredRoot = path.join(tempDir, 'plugin');
217+
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
218+
fs.mkdirSync(declaredRoot);
219+
const canonicalRoot = fs.realpathSync(declaredRoot);
220+
const candidate = path.join(canonicalRoot, 'scripts', 'launch-playwright-mcp.js');
221+
const exactParent = path.dirname(canonicalRoot);
222+
223+
const result = runBootstrap({
224+
cwd: tempDir,
225+
pluginRoot: declaredRoot,
226+
realpathOverride: { target: candidate, result: exactParent },
227+
});
228+
229+
assert.notEqual(result.status, 0);
230+
assert.match(
231+
result.stderr,
232+
/Error: \[Power Pages Playwright MCP\] Resolved launcher escapes the declared plugin root:/,
233+
);
234+
assert.doesNotMatch(
235+
result.stderr,
236+
/Error: \[Power Pages Playwright MCP\] Resolved launcher is not a file:/,
237+
);
238+
});
239+
152240
test('playwright MCP bootstrap supports installed-plugin root environment conventions', async (t) => {
153241
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'power-pages-mcp-'));
154242
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));

0 commit comments

Comments
 (0)