Skip to content

Commit e4b6d16

Browse files
authored
fix: prevent out-of-workspace-root access for open-in-editor and open-in-file (#255)
1 parent d690ee2 commit e4b6d16

3 files changed

Lines changed: 75 additions & 4 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { DevToolsNodeContext } from '@vitejs/devtools-kit'
2+
import { resolve } from 'node:path'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import { openInEditor } from '../rpc/public/open-in-editor'
5+
6+
// Mock launch-editor so tests don't actually open files
7+
vi.mock('launch-editor', () => ({
8+
default: vi.fn(),
9+
}))
10+
11+
describe('openInEditor – path traversal protection', () => {
12+
const cwd = resolve('/project/root')
13+
const workspaceRoot = resolve('/project')
14+
const mockContext = { cwd, workspaceRoot } as DevToolsNodeContext
15+
16+
async function getHandler() {
17+
const setup = openInEditor.setup!
18+
const { handler } = await setup(mockContext)
19+
expect(handler).toBeTypeOf('function')
20+
return handler as (path: string) => Promise<void>
21+
}
22+
23+
it('allows opening a file inside the project root', async () => {
24+
const handler = await getHandler()
25+
await expect(handler('src/main.ts')).resolves.not.toThrow()
26+
})
27+
28+
it('allows opening a nested file inside the project root', async () => {
29+
const handler = await getHandler()
30+
await expect(handler('src/utils/helper.ts')).resolves.not.toThrow()
31+
})
32+
33+
it('rejects path traversal with ../', async () => {
34+
const handler = await getHandler()
35+
await expect(handler('../../etc/passwd')).rejects.toThrow(
36+
'Path is outside the workspace root',
37+
)
38+
})
39+
40+
it('rejects absolute path outside project root', async () => {
41+
const handler = await getHandler()
42+
await expect(handler('/etc/passwd')).rejects.toThrow(
43+
'Path is outside the workspace root',
44+
)
45+
})
46+
47+
it('rejects traversal disguised within a subpath', async () => {
48+
const handler = await getHandler()
49+
await expect(handler('src/../../secret/file.txt')).rejects.toThrow(
50+
'Path is outside the workspace root',
51+
)
52+
})
53+
})

packages/core/src/node/rpc/public/open-in-editor.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1+
import { relative, resolve } from 'node:path'
12
import { defineRpcFunction } from '@vitejs/devtools-kit'
23

34
export const openInEditor = defineRpcFunction({
45
name: 'vite:core:open-in-editor',
56
type: 'action',
6-
setup: () => {
7+
setup: (context) => {
78
return {
89
handler: async (path: string) => {
9-
await import('launch-editor').then(r => r.default(path))
10+
const resolved = resolve(context.workspaceRoot, path)
11+
const rel = relative(context.workspaceRoot, resolved)
12+
13+
// Prevent escaping the workspace root
14+
if (rel.startsWith('..') || rel.includes('\0')) {
15+
throw new Error('Path is outside the workspace root')
16+
}
17+
18+
await import('launch-editor').then(r => r.default(resolved))
1019
},
1120
}
1221
},

packages/core/src/node/rpc/public/open-in-finder.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1+
import { relative, resolve } from 'node:path'
12
import { defineRpcFunction } from '@vitejs/devtools-kit'
23

34
export const openInFinder = defineRpcFunction({
45
name: 'vite:core:open-in-finder',
56
type: 'action',
6-
setup: () => {
7+
setup: (context) => {
78
return {
89
handler: async (path: string) => {
9-
await import('open').then(r => r.default(path))
10+
const resolved = resolve(context.workspaceRoot, path)
11+
const rel = relative(context.workspaceRoot, resolved)
12+
13+
// Ensure the path stays within workspace root
14+
if (rel.startsWith('..') || rel.includes('\0')) {
15+
throw new Error('Path is outside the workspace root')
16+
}
17+
18+
await import('open').then(r => r.default(resolved))
1019
},
1120
}
1221
},

0 commit comments

Comments
 (0)