Skip to content

Commit 04aa046

Browse files
fix(security): resolve symlinks in workspace boundary check (#169)
isPathOutsideWorkspace() only normalized ./.. so a symlink living inside the workspace but pointing outside passed the check, trivially bypassing the out-of-workspace read protection. Resolve the real path (following symlinks) for both the target and the workspace folders before comparing. Paths that don't exist yet resolve via their nearest existing ancestor. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 45b239c commit 04aa046

2 files changed

Lines changed: 117 additions & 3 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import * as fs from "fs"
2+
import * as os from "os"
3+
import * as path from "path"
4+
5+
import { isPathOutsideWorkspace } from "../pathUtils"
6+
7+
// Mutable workspaceFolders the mocked vscode module reads from.
8+
const { mockWorkspace } = vi.hoisted(() => ({
9+
mockWorkspace: { folders: [] as Array<{ uri: { fsPath: string } }> },
10+
}))
11+
12+
vi.mock("vscode", () => ({
13+
workspace: {
14+
get workspaceFolders() {
15+
return mockWorkspace.folders.length > 0 ? mockWorkspace.folders : undefined
16+
},
17+
},
18+
}))
19+
20+
describe("isPathOutsideWorkspace", () => {
21+
let tmpRoot: string
22+
let workspaceDir: string
23+
let outsideDir: string
24+
25+
beforeEach(() => {
26+
// realpath the tmp dir because macOS resolves /var -> /private/var
27+
tmpRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "zoo-pathutils-")))
28+
workspaceDir = path.join(tmpRoot, "workspace")
29+
outsideDir = path.join(tmpRoot, "outside")
30+
fs.mkdirSync(workspaceDir)
31+
fs.mkdirSync(outsideDir)
32+
mockWorkspace.folders = [{ uri: { fsPath: workspaceDir } }]
33+
})
34+
35+
afterEach(() => {
36+
mockWorkspace.folders = []
37+
fs.rmSync(tmpRoot, { recursive: true, force: true })
38+
})
39+
40+
it("treats a real file inside the workspace as inside", () => {
41+
const inside = path.join(workspaceDir, "file.ts")
42+
fs.writeFileSync(inside, "x")
43+
expect(isPathOutsideWorkspace(inside)).toBe(false)
44+
})
45+
46+
it("treats a real file outside the workspace as outside", () => {
47+
const outside = path.join(outsideDir, "secret.txt")
48+
fs.writeFileSync(outside, "secret")
49+
expect(isPathOutsideWorkspace(outside)).toBe(true)
50+
})
51+
52+
it("treats a not-yet-existing file inside the workspace as inside", () => {
53+
// File about to be created — realpath of the parent (workspace) still resolves.
54+
expect(isPathOutsideWorkspace(path.join(workspaceDir, "new-file.ts"))).toBe(false)
55+
})
56+
57+
it("treats a symlink inside the workspace that points outside as OUTSIDE (#169)", () => {
58+
const secret = path.join(outsideDir, "secret.txt")
59+
fs.writeFileSync(secret, "secret")
60+
const link = path.join(workspaceDir, "link-to-secret.txt")
61+
fs.symlinkSync(secret, link)
62+
63+
// Lexically the link lives inside the workspace, but it resolves outside.
64+
expect(isPathOutsideWorkspace(link)).toBe(true)
65+
})
66+
67+
it("treats a symlinked directory inside the workspace that points outside as OUTSIDE (#169)", () => {
68+
fs.writeFileSync(path.join(outsideDir, "deep.txt"), "secret")
69+
const linkDir = path.join(workspaceDir, "linked-dir")
70+
fs.symlinkSync(outsideDir, linkDir)
71+
72+
expect(isPathOutsideWorkspace(path.join(linkDir, "deep.txt"))).toBe(true)
73+
})
74+
75+
it("returns true when there are no workspace folders", () => {
76+
mockWorkspace.folders = []
77+
expect(isPathOutsideWorkspace(path.join(workspaceDir, "file.ts"))).toBe(true)
78+
})
79+
})

src/utils/pathUtils.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,36 @@
11
import * as vscode from "vscode"
22
import * as path from "path"
3+
import * as fs from "fs"
4+
5+
/**
6+
* Resolves a path to its canonical form, following symlinks.
7+
*
8+
* If the path does not exist yet (e.g. a file that is about to be created), the
9+
* realpath of the nearest existing ancestor is resolved and the remaining
10+
* segments are re-appended. This ensures a symlink anywhere along the path is
11+
* still followed, while paths that don't exist yet can still be evaluated.
12+
*/
13+
function realPathOrNearest(target: string): string {
14+
let current = path.resolve(target)
15+
const trailing: string[] = []
16+
17+
// Walk up until an existing path can be resolved, bounded by the filesystem root.
18+
while (true) {
19+
try {
20+
const resolved = fs.realpathSync.native(current)
21+
return trailing.length > 0 ? path.join(resolved, ...trailing.reverse()) : resolved
22+
} catch {
23+
const parent = path.dirname(current)
24+
if (parent === current) {
25+
// Reached the root without finding an existing path; fall back to the
26+
// lexically resolved path.
27+
return path.resolve(target)
28+
}
29+
trailing.push(path.basename(current))
30+
current = parent
31+
}
32+
}
33+
}
334

435
/**
536
* Checks if a file path is outside all workspace folders
@@ -12,12 +43,16 @@ export function isPathOutsideWorkspace(filePath: string): boolean {
1243
return true
1344
}
1445

15-
// Normalize and resolve the path to handle .. and . components correctly
16-
const absolutePath = path.resolve(filePath)
46+
// Resolve symlinks (not just "." / "..") so a symlink that lives inside the
47+
// workspace but points outside it is correctly treated as outside. Without
48+
// this, the out-of-workspace read protection was trivially bypassed by
49+
// symlinking to a file outside the workspace. See issue #169.
50+
const absolutePath = realPathOrNearest(filePath)
1751

1852
// Check if the path is within any workspace folder
1953
return !vscode.workspace.workspaceFolders.some((folder) => {
20-
const folderPath = folder.uri.fsPath
54+
// Resolve the workspace folder too, in case it is itself reached via a symlink.
55+
const folderPath = realPathOrNearest(folder.uri.fsPath)
2156
// Path is inside a workspace if it equals the workspace path or is a subfolder
2257
return absolutePath === folderPath || absolutePath.startsWith(folderPath + path.sep)
2358
})

0 commit comments

Comments
 (0)