feat: ensure extensions for file outputs (#1867)

This PR ensures the extensions for the file outputs of different types
minimizing the chance of misuse. The input filePath, thus, might be
modified but it should not be an issue for clients as the final output
path is returned to the clients in the response.

Closes https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/1864

---------

Co-authored-by: Nikolay Vitkov <34244704+Lightning00Blade@users.noreply.github.com>
This commit is contained in:
Alex Rudenko
2026-04-16 08:08:22 +02:00
committed by GitHub
parent 93691110b9
commit e7a0d50977
12 changed files with 116 additions and 21 deletions
+12 -4
View File
@@ -33,7 +33,11 @@ import {Locator} from './third_party/index.js';
import {PredefinedNetworkConditions} from './third_party/index.js';
import {listPages} from './tools/pages.js';
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
import type {Context, DevToolsData} from './tools/ToolDefinition.js';
import type {
Context,
DevToolsData,
SupportedExtensions,
} from './tools/ToolDefinition.js';
import type {TraceResult} from './trace-processing/parse.js';
import type {
EmulationSettings,
@@ -46,7 +50,7 @@ import {
ExtensionRegistry,
type InstalledExtension,
} from './utils/ExtensionRegistry.js';
import {saveTemporaryFile} from './utils/files.js';
import {ensureExtension, saveTemporaryFile} from './utils/files.js';
import {getNetworkMultiplierFromString} from './WaitForHelper.js';
interface McpContextOptions {
@@ -801,10 +805,14 @@ export class McpContext implements Context {
}
async saveFile(
data: Uint8Array<ArrayBufferLike>,
filename: string,
clientProvidedFilePath: string,
extension: SupportedExtensions,
): Promise<{filename: string}> {
try {
const filePath = path.resolve(filename);
const filePath = ensureExtension(
path.resolve(clientProvidedFilePath),
extension,
);
await fs.mkdir(path.dirname(filePath), {recursive: true});
await fs.writeFile(filePath, data);
return {filename: filePath};
+7 -4
View File
@@ -403,11 +403,12 @@ export class McpResponse implements Response {
if (textSnapshot) {
const formatter = new SnapshotFormatter(textSnapshot);
if (this.#snapshotParams.filePath) {
await context.saveFile(
const result = await context.saveFile(
new TextEncoder().encode(formatter.toString()),
this.#snapshotParams.filePath,
'.txt',
);
snapshot = this.#snapshotParams.filePath;
snapshot = result.filename;
} else {
snapshot = formatter;
}
@@ -429,7 +430,8 @@ export class McpResponse implements Response {
fetchData: true,
requestFilePath: this.#attachedNetworkRequestOptions?.requestFilePath,
responseFilePath: this.#attachedNetworkRequestOptions?.responseFilePath,
saveFile: (data, filename) => context.saveFile(data, filename),
saveFile: (data, filename, extension) =>
context.saveFile(data, filename, extension),
redactNetworkHeaders: this.#redactNetworkHeaders,
});
detailedNetworkRequest = formatter;
@@ -573,7 +575,8 @@ export class McpResponse implements Response {
context.getNetworkRequestStableId(request) ===
this.#networkRequestsOptions?.networkRequestIdInDevToolsUI,
fetchData: false,
saveFile: (data, filename) => context.saveFile(data, filename),
saveFile: (data, filename, extension) =>
context.saveFile(data, filename, extension),
redactNetworkHeaders: this.#redactNetworkHeaders,
}),
),
+10 -4
View File
@@ -24,6 +24,7 @@ export interface NetworkFormatterOptions {
saveFile?: (
data: Uint8Array<ArrayBufferLike>,
filename: string,
extension: '.network-request' | '.network-response',
) => Promise<{filename: string}>;
redactNetworkHeaders: boolean;
}
@@ -88,11 +89,12 @@ export class NetworkFormatter {
throw new Error('saveFile is not provided');
}
if (data) {
await this.#options.saveFile(
const result = await this.#options.saveFile(
Buffer.from(data),
this.#options.requestFilePath,
'.network-request',
);
this.#requestBodyFilePath = this.#options.requestFilePath;
this.#requestBodyFilePath = result.filename;
} else {
this.#requestBody = requestBodyNotAvailableMessage;
}
@@ -119,8 +121,12 @@ export class NetworkFormatter {
if (!this.#options.saveFile) {
throw new Error('saveFile is not provided');
}
await this.#options.saveFile(buffer, this.#options.responseFilePath);
this.#responseBodyFilePath = this.#options.responseFilePath;
const result = await this.#options.saveFile(
buffer,
this.#options.responseFilePath,
'.network-response',
);
this.#responseBodyFilePath = result.filename;
} catch {
// Flatten error handling for buffer() failure and save failure
}
+14 -1
View File
@@ -136,6 +136,18 @@ export interface Response {
setListInPageTools(): void;
}
export type SupportedExtensions =
| '.png'
| '.jpeg'
| '.webp'
| '.json'
| '.network-response'
| '.network-request'
| '.html'
| '.txt'
| '.csv'
| '.json.gz';
/**
* Only add methods required by tools/*.
*/
@@ -170,7 +182,8 @@ export type Context = Readonly<{
): Promise<{filepath: string}>;
saveFile(
data: Uint8Array<ArrayBufferLike>,
filename: string,
clientProvidedFilePath: string,
extension: SupportedExtensions,
): Promise<{filename: string}>;
waitForTextOnPage(
text: string[],
+6 -2
View File
@@ -107,8 +107,12 @@ export const lighthouseAudit = definePageTool({
const report = generateReport(lhr, format);
const data = encoder.encode(report);
if (outputDirPath) {
const reportPath = path.join(outputDirPath, `report.${format}`);
const {filename} = await context.saveFile(data, reportPath);
const reportPath = path.join(outputDirPath, `report`);
const {filename} = await context.saveFile(
data,
reportPath,
`.${format}`,
);
reportPaths.push(filename);
} else {
const {filepath} = await context.saveTemporaryFile(
+2 -1
View File
@@ -5,6 +5,7 @@
*/
import {zod} from '../third_party/index.js';
import {ensureExtension} from '../utils/files.js';
import {ToolCategory} from './categories.js';
import {definePageTool} from './ToolDefinition.js';
@@ -25,7 +26,7 @@ export const takeMemorySnapshot = definePageTool({
const page = request.page;
await page.pptrPage.captureHeapSnapshot({
path: request.params.filePath,
path: ensureExtension(request.params.filePath, '.heapsnapshot'),
});
response.appendResponseLine(
+5 -1
View File
@@ -197,7 +197,11 @@ async function stopTracingAndAppendOutput(
});
});
}
const file = await context.saveFile(dataToWrite, filePath);
const file = await context.saveFile(
dataToWrite,
filePath,
filePath.endsWith('.gz') ? '.json.gz' : '.json',
);
response.appendResponseLine(
`The raw trace data was saved to ${file.filename}.`,
);
+2 -1
View File
@@ -10,6 +10,7 @@ import path from 'node:path';
import {zod} from '../third_party/index.js';
import type {ScreenRecorder} from '../third_party/index.js';
import {ensureExtension} from '../utils/files.js';
import {ToolCategory} from './categories.js';
import {definePageTool} from './ToolDefinition.js';
@@ -46,7 +47,7 @@ export const startScreencast = definePageTool({
}
const filePath = request.params.path ?? (await generateTempFilePath());
const resolvedPath = path.resolve(filePath);
const resolvedPath = ensureExtension(path.resolve(filePath), '.mp4');
const page = request.page;
+6 -2
View File
@@ -87,8 +87,12 @@ export const screenshot = definePageTool({
}
if (request.params.filePath) {
const file = await context.saveFile(screenshot, request.params.filePath);
response.appendResponseLine(`Saved screenshot to ${file.filename}.`);
const result = await context.saveFile(
screenshot,
request.params.filePath,
`.${format}`,
);
response.appendResponseLine(`Saved screenshot to ${result.filename}.`);
} else if (screenshot.length >= 2_000_000) {
const {filepath} = await context.saveTemporaryFile(
screenshot,
+8
View File
@@ -24,3 +24,11 @@ export async function saveTemporaryFile(
throw new Error('Could not save a file', {cause: err});
}
}
export function ensureExtension(
filepath: string,
extension: `.${string}`,
): string {
const ext = path.extname(filepath);
return filepath.slice(0, filepath.length - ext.length) + extension;
}
+1 -1
View File
@@ -156,7 +156,7 @@ describe('McpResponse', () => {
});
it('saves snapshot to file and returns structured content', async t => {
const filePath = join(tmpdir(), 'test-screenshot.png');
const filePath = join(tmpdir(), 'test-snapshot.txt');
try {
await withMcpContext(async (response, context) => {
const page = context.getSelectedPptrPage();
+43
View File
@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {describe, it} from 'node:test';
import {ensureExtension} from '../../src/utils/files.js';
describe('ensureExtension', () => {
it('should add an extension to a filename without one', () => {
assert.strictEqual(ensureExtension('filename', '.txt'), 'filename.txt');
});
it('should replace an existing extension', () => {
assert.strictEqual(ensureExtension('filename.jpg', '.txt'), 'filename.txt');
});
it('should handle extension without a leading dot', () => {
assert.strictEqual(ensureExtension('filename', '.txt'), 'filename.txt');
});
it('should not add a second dot if already present', () => {
assert.strictEqual(ensureExtension('filename.txt', '.txt'), 'filename.txt');
});
it('should handle paths with directories', () => {
assert.strictEqual(
ensureExtension('/path/to/file.jpg', '.png'),
'/path/to/file.png',
);
});
it('should handle hidden files (starting with dot)', () => {
assert.strictEqual(ensureExtension('.bashrc', '.txt'), '.bashrc.txt');
});
it('should handle complex extensions (like .tar.gz) - path.extname only gets the last one', () => {
assert.strictEqual(ensureExtension('file.tar.gz', '.zip'), 'file.tar.zip');
});
});