Skip to content

Commit 2f820cb

Browse files
authored
fix(mcp): lax file path sanitization (#37502)
1 parent ec8edbc commit 2f820cb

File tree

9 files changed

+32
-14
lines changed

9 files changed

+32
-14
lines changed

packages/playwright/src/mcp/browser/browserContextFactory.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ class IsolatedContextFactory extends BaseContextFactory {
106106
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
107107
await injectCdpPort(this.config.browser);
108108
const browserType = playwright[this.config.browser.browserName];
109-
const tracesDir = await outputFile(this.config, clientInfo, `traces`);
109+
const tracesDir = await outputFile(this.config, clientInfo, `traces`, { origin: 'code' });
110110
if (this.config.saveTrace)
111111
await startTraceServer(this.config, tracesDir);
112112
return browserType.launch({
@@ -173,7 +173,7 @@ class PersistentContextFactory implements BrowserContextFactory {
173173
await injectCdpPort(this.config.browser);
174174
testDebug('create browser context (persistent)');
175175
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo);
176-
const tracesDir = await outputFile(this.config, clientInfo, `traces`);
176+
const tracesDir = await outputFile(this.config, clientInfo, `traces`, { origin: 'code' });
177177
if (this.config.saveTrace)
178178
await startTraceServer(this.config, tracesDir);
179179

packages/playwright/src/mcp/browser/config.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -271,15 +271,27 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
271271
}
272272
}
273273

274-
export async function outputFile(config: FullConfig, clientInfo: ClientInfo, name: string): Promise<string> {
274+
export async function outputFile(config: FullConfig, clientInfo: ClientInfo, fileName: string, options: { origin: 'code' | 'llm' | 'web' }): Promise<string> {
275275
const rootPath = firstRootPath(clientInfo);
276276
const outputDir = config.outputDir
277277
?? (rootPath ? path.join(rootPath, '.playwright-mcp') : undefined)
278278
?? path.join(process.env.PW_TMPDIR_FOR_TEST ?? os.tmpdir(), 'playwright-mcp-output', String(clientInfo.timestamp));
279279

280-
await fs.promises.mkdir(outputDir, { recursive: true });
281-
const fileName = sanitizeForFilePath(name);
282-
return path.join(outputDir, fileName);
280+
// Trust code.
281+
if (options.origin === 'code')
282+
return path.resolve(outputDir, fileName);
283+
284+
// Trust llm to use valid characters in file names.
285+
if (options.origin === 'llm') {
286+
fileName = fileName.split('\\').join('/');
287+
const resolvedFile = path.resolve(outputDir, fileName);
288+
if (!resolvedFile.startsWith(path.resolve(outputDir) + path.sep))
289+
throw new Error(`Resolved file path for ${fileName} is outside of the output directory`);
290+
return resolvedFile;
291+
}
292+
293+
// Do not trust web, at all.
294+
return path.join(outputDir, sanitizeForFilePath(fileName));
283295
}
284296

285297
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {

packages/playwright/src/mcp/browser/context.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ export class Context {
113113
return url;
114114
}
115115

116-
async outputFile(name: string): Promise<string> {
117-
return outputFile(this.config, this._clientInfo, name);
116+
async outputFile(fileName: string, options: { origin: 'code' | 'llm' | 'web' }): Promise<string> {
117+
return outputFile(this.config, this._clientInfo, fileName, options);
118118
}
119119

120120
private _onPageCreated(page: playwright.Page) {

packages/playwright/src/mcp/browser/sessionLog.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export class SessionLog {
5353
}
5454

5555
static async create(config: FullConfig, clientInfo: mcpServer.ClientInfo): Promise<SessionLog> {
56-
const sessionFolder = await outputFile(config, clientInfo, `session-${Date.now()}`);
56+
const sessionFolder = await outputFile(config, clientInfo, `session-${Date.now()}`, { origin: 'code' });
5757
await fs.promises.mkdir(sessionFolder, { recursive: true });
5858
// eslint-disable-next-line no-console
5959
console.error(`Session: ${sessionFolder}`);

packages/playwright/src/mcp/browser/tab.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export class Tab extends EventEmitter<TabEventsInterface> {
117117
const entry = {
118118
download,
119119
finished: false,
120-
outputFile: await this.context.outputFile(download.suggestedFilename())
120+
outputFile: await this.context.outputFile(download.suggestedFilename(), { origin: 'web' })
121121
};
122122
this._downloads.push(entry);
123123
await download.saveAs(entry.outputFile);

packages/playwright/src/mcp/browser/tools/pdf.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import { z } from '../../sdk/bundle';
1818
import { defineTabTool } from './tool';
1919
import * as javascript from '../codegen';
20+
import { dateAsFileName } from './utils';
2021

2122
const pdfSchema = z.object({
2223
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
@@ -34,7 +35,7 @@ const pdf = defineTabTool({
3435
},
3536

3637
handle: async (tab, params, response) => {
37-
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.pdf`);
38+
const fileName = await tab.context.outputFile(params.filename ?? `page-${dateAsFileName()}.pdf`, { origin: 'llm' });
3839
response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`);
3940
response.addResult(`Saved page as ${fileName}`);
4041
await tab.page.pdf({ path: fileName });

packages/playwright/src/mcp/browser/tools/screenshot.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import { z } from '../../sdk/bundle';
1818
import { defineTabTool } from './tool';
1919
import * as javascript from '../codegen';
20-
import { generateLocator } from './utils';
20+
import { generateLocator, dateAsFileName } from './utils';
2121

2222
import type * as playwright from 'playwright-core';
2323

@@ -51,7 +51,7 @@ const screenshot = defineTabTool({
5151

5252
handle: async (tab, params, response) => {
5353
const fileType = params.type || 'png';
54-
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
54+
const fileName = await tab.context.outputFile(params.filename ?? `page-${dateAsFileName()}.${fileType}`, { origin: 'llm' });
5555
const options: playwright.PageScreenshotOptions = {
5656
type: fileType,
5757
quality: fileType === 'png' ? undefined : 90,

packages/playwright/src/mcp/browser/tools/tracing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const tracingStart = defineTool({
3232

3333
handle: async (context, params, response) => {
3434
const browserContext = await context.ensureBrowserContext();
35-
const tracesDir = await context.outputFile(`traces`);
35+
const tracesDir = await context.outputFile(`traces`, { origin: 'code' });
3636
const name = 'trace-' + Date.now();
3737
await (browserContext.tracing as Tracing).start({
3838
name,

packages/playwright/src/mcp/browser/tools/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,8 @@ export async function generateLocator(locator: playwright.Locator): Promise<stri
8282
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
8383
return await (page as any)._wrapApiCall(() => callback(page), { internal: true });
8484
}
85+
86+
export function dateAsFileName(): string {
87+
const date = new Date();
88+
return date.toISOString().replace(/[:.]/g, '-');
89+
}

0 commit comments

Comments
 (0)