Skip to content

Commit da1f8c5

Browse files
fix(security): resolve symlinks in workspace boundary check (Zoo-Code-Org#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.
1 parent b761a0a commit da1f8c5

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)