Skip to content

Commit a922814

Browse files
fix: validate extension-enforced output paths (#2269)
Fixes output path validation so tools cannot validate one path and then write to a different canonical target after extension enforcement. Changes: - Resolve dangling symlinks to their target path during canonicalization. - Validate the final extension-enforced output path before writing. - Apply the same final-path validation to heap snapshots and screencasts. - Add regression coverage for dangling symlinks that point outside configured roots. Validation: - npm run format - npm run check-format - npm run test tests/utils/files.test.ts - npm run test tests/roots.test.ts - npm run test tests/tools/memory.test.ts tests/tools/screencast.test.ts Note: I also ran the full npm test suite locally. The targeted tests above passed, but the full suite hit local WSL daemon/e2e startup timeouts while waiting for daemon.pid / server_start, which appear unrelated to this path-validation change. --------- Co-authored-by: huynhtrungcsc <huynhtrungcsc@users.noreply.github.com>
1 parent cf00305 commit a922814

7 files changed

Lines changed: 121 additions & 64 deletions

File tree

src/McpContext.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,7 @@ import type {
5454
GeolocationOptions,
5555
ExtensionServiceWorker,
5656
} from './types.js';
57-
import {
58-
ensureExtension,
59-
getTempFilePath,
60-
resolveCanonicalPath,
61-
} from './utils/files.js';
57+
import {getTempFilePath, resolveCanonicalPath} from './utils/files.js';
6258
import {getNetworkMultiplierFromString} from './WaitForHelper.js';
6359

6460
interface McpContextOptions {
@@ -258,6 +254,20 @@ export class McpContext implements Context {
258254
}
259255
}
260256

257+
async ensureExtension<Extension extends `.${string}`>(
258+
filePath: string,
259+
extension: Extension,
260+
): Promise<`${string}${Extension}`> {
261+
const resolvedPath = path.resolve(filePath);
262+
const currentExtension = path.extname(resolvedPath);
263+
const outputPath: `${string}${Extension}` = `${resolvedPath.slice(
264+
0,
265+
resolvedPath.length - currentExtension.length,
266+
)}${extension}`;
267+
await this.validatePath(outputPath);
268+
return outputPath;
269+
}
270+
261271
resolveCdpRequestId(page: McpPage, cdpRequestId: string): number | undefined {
262272
if (!cdpRequestId) {
263273
this.logger?.('no network request');
@@ -800,12 +810,11 @@ export class McpContext implements Context {
800810
clientProvidedFilePath: string,
801811
extension: SupportedExtensions,
802812
): Promise<{filename: string}> {
803-
await this.validatePath(clientProvidedFilePath);
813+
const filePath = await this.ensureExtension(
814+
clientProvidedFilePath,
815+
extension,
816+
);
804817
try {
805-
const filePath = ensureExtension(
806-
path.resolve(clientProvidedFilePath),
807-
extension,
808-
);
809818
await fs.mkdir(path.dirname(filePath), {recursive: true});
810819
await fs.writeFile(filePath, data);
811820
return {filename: filePath};

src/tools/ToolDefinition.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,10 @@ export type SupportedExtensions =
187187
*/
188188
export type Context = Readonly<{
189189
validatePath(filePath?: string): Promise<void>;
190+
ensureExtension<Extension extends `.${string}`>(
191+
filePath: string,
192+
extension: Extension,
193+
): Promise<`${string}${Extension}`>;
190194
isRunningPerformanceTrace(): boolean;
191195
setIsRunningPerformanceTrace(x: boolean): void;
192196
isCruxEnabled(): boolean;

src/tools/memory.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
*/
66

77
import {zod} from '../third_party/index.js';
8-
import {ensureExtension} from '../utils/files.js';
98

109
import {ToolCategory} from './categories.js';
1110
import {definePageTool, defineTool} from './ToolDefinition.js';
@@ -24,16 +23,18 @@ export const takeHeapSnapshot = definePageTool({
2423
},
2524
blockedByDialog: true,
2625
verifyFilesSchema: ['filePath'],
27-
handler: async (request, response) => {
26+
handler: async (request, response, context) => {
2827
const page = request.page;
28+
const snapshotPath = await context.ensureExtension(
29+
request.params.filePath,
30+
'.heapsnapshot',
31+
);
2932

3033
await page.pptrPage.captureHeapSnapshot({
31-
path: ensureExtension(request.params.filePath, '.heapsnapshot'),
34+
path: snapshotPath,
3235
});
3336

34-
response.appendResponseLine(
35-
`Heap snapshot saved to ${request.params.filePath}`,
36-
);
37+
response.appendResponseLine(`Heap snapshot saved to ${snapshotPath}`);
3738
},
3839
});
3940

src/tools/screencast.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import path from 'node:path';
1010

1111
import {zod} from '../third_party/index.js';
1212
import type {ScreenRecorder, VideoFormat} from '../third_party/index.js';
13-
import {ensureExtension} from '../utils/files.js';
1413

1514
import {ToolCategory} from './categories.js';
1615
import {definePageTool} from './ToolDefinition.js';
@@ -20,7 +19,9 @@ async function generateTempFilePath(): Promise<string> {
2019
return path.join(dir, `screencast.mp4`);
2120
}
2221

23-
const supportedExtensions: Array<`.${string}`> = ['.webm', '.mp4'];
22+
type SupportedVideoExtension = '.webm' | '.mp4';
23+
24+
const supportedExtensions: SupportedVideoExtension[] = ['.webm', '.mp4'];
2425

2526
export const startScreencast = definePageTool(args => ({
2627
name: 'screencast_start',
@@ -68,14 +69,15 @@ export const startScreencast = definePageTool(args => ({
6869
`Supported formats: ${supportedExtensions.join(', ')} (case-insensitive).`,
6970
);
7071
}
71-
const enforcedExtension: `.${string}` = matchedExtension ?? '.mp4';
72+
const enforcedExtension: SupportedVideoExtension =
73+
matchedExtension ?? '.mp4';
7274
const format: VideoFormat = (matchedExtension?.substring(1) ??
7375
'mp4') as VideoFormat;
7476

75-
const resolvedPath = ensureExtension(
76-
path.resolve(filePath),
77+
const resolvedPath = await context.ensureExtension(
78+
filePath,
7779
enforcedExtension,
78-
) as `${string}.webm`;
80+
);
7981

8082
const page = request.page;
8183

src/utils/files.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,6 @@ export async function getTempFilePath(filename: string) {
1515
return filepath;
1616
}
1717

18-
export function ensureExtension(
19-
filepath: string,
20-
extension: `.${string}`,
21-
): string {
22-
const ext = path.extname(filepath);
23-
return filepath.slice(0, filepath.length - ext.length) + extension;
24-
}
25-
2618
export async function resolveCanonicalPath(filePath: string): Promise<string> {
2719
const absolutePath = path.resolve(filePath);
2820
try {

tests/roots.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,86 @@ describe('McpContext Roots', () => {
5454
}
5555
});
5656
});
57+
58+
it('should enforce extensions and validate the output path', async () => {
59+
await withMcpContext(async (_response, context) => {
60+
const workspacePath = await fs.mkdtemp(
61+
path.join(os.tmpdir(), 'workspace-root-'),
62+
);
63+
try {
64+
context.setRoots([
65+
{uri: pathToFileURL(workspacePath).href, name: 'workspace'},
66+
]);
67+
68+
const testCases: Array<{
69+
filePath: string;
70+
extension: '.json' | '.txt' | '.png' | '.zip';
71+
expected: string;
72+
}> = [
73+
{
74+
filePath: 'result',
75+
extension: '.json',
76+
expected: 'result.json',
77+
},
78+
{
79+
filePath: 'result.jpg',
80+
extension: '.txt',
81+
expected: 'result.txt',
82+
},
83+
{
84+
filePath: 'nested/result.jpg',
85+
extension: '.png',
86+
expected: 'nested/result.png',
87+
},
88+
{
89+
filePath: '.bashrc',
90+
extension: '.txt',
91+
expected: '.bashrc.txt',
92+
},
93+
{
94+
filePath: 'file.tar.gz',
95+
extension: '.zip',
96+
expected: 'file.tar.zip',
97+
},
98+
];
99+
100+
for (const testCase of testCases) {
101+
const resolvedPath = await context.ensureExtension(
102+
path.join(workspacePath, testCase.filePath),
103+
testCase.extension,
104+
);
105+
106+
assert.strictEqual(
107+
resolvedPath,
108+
path.join(workspacePath, testCase.expected),
109+
);
110+
}
111+
} finally {
112+
await fs.rm(workspacePath, {recursive: true, force: true});
113+
}
114+
});
115+
});
116+
117+
it('should deny extension-enforced paths outside roots', async () => {
118+
await withMcpContext(async (_response, context) => {
119+
const workspacePath = await fs.mkdtemp(
120+
path.join(os.tmpdir(), 'workspace-root-'),
121+
);
122+
try {
123+
context.setRoots([
124+
{uri: pathToFileURL(workspacePath).href, name: 'workspace'},
125+
]);
126+
127+
await assert.rejects(
128+
context.ensureExtension(
129+
path.join(os.homedir(), 'outside-root-result'),
130+
'.json',
131+
),
132+
/Access denied/,
133+
);
134+
} finally {
135+
await fs.rm(workspacePath, {recursive: true, force: true});
136+
}
137+
});
138+
});
57139
});

tests/utils/files.test.ts

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,40 +10,7 @@ import os from 'node:os';
1010
import path from 'node:path';
1111
import {describe, it} from 'node:test';
1212

13-
import {ensureExtension, resolveCanonicalPath} from '../../src/utils/files.js';
14-
15-
describe('ensureExtension', () => {
16-
it('should add an extension to a filename without one', () => {
17-
assert.strictEqual(ensureExtension('filename', '.txt'), 'filename.txt');
18-
});
19-
20-
it('should replace an existing extension', () => {
21-
assert.strictEqual(ensureExtension('filename.jpg', '.txt'), 'filename.txt');
22-
});
23-
24-
it('should handle extension without a leading dot', () => {
25-
assert.strictEqual(ensureExtension('filename', '.txt'), 'filename.txt');
26-
});
27-
28-
it('should not add a second dot if already present', () => {
29-
assert.strictEqual(ensureExtension('filename.txt', '.txt'), 'filename.txt');
30-
});
31-
32-
it('should handle paths with directories', () => {
33-
assert.strictEqual(
34-
ensureExtension('/path/to/file.jpg', '.png'),
35-
'/path/to/file.png',
36-
);
37-
});
38-
39-
it('should handle hidden files (starting with dot)', () => {
40-
assert.strictEqual(ensureExtension('.bashrc', '.txt'), '.bashrc.txt');
41-
});
42-
43-
it('should handle complex extensions (like .tar.gz) - path.extname only gets the last one', () => {
44-
assert.strictEqual(ensureExtension('file.tar.gz', '.zip'), 'file.tar.zip');
45-
});
46-
});
13+
import {resolveCanonicalPath} from '../../src/utils/files.js';
4714

4815
describe('resolveCanonicalPath', () => {
4916
it('should resolve an existing standard file path', async () => {

0 commit comments

Comments
 (0)