Skip to content

Commit 49a70d9

Browse files
fix(security): fail closed on non-ENOENT realpath errors (#169)
Per @edelauna's review: only ENOENT triggers the nearest-ancestor walk-up. Any other error (e.g. EACCES on a symlink whose target has restricted permissions) now propagates, and isPathOutsideWorkspace fails closed — treating the path as outside instead of masking the symlink with the lexical path. Adds a regression test that stubs realpath to throw EACCES. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5c29c28 commit 49a70d9

2 files changed

Lines changed: 53 additions & 3 deletions

File tree

src/utils/__tests__/pathUtils.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,26 @@ describe("isPathOutsideWorkspace", () => {
7272
expect(isPathOutsideWorkspace(path.join(linkDir, "deep.txt"))).toBe(true)
7373
})
7474

75+
it("fails closed when symlink resolution throws a non-ENOENT error such as EACCES (#169)", () => {
76+
const restricted = path.join(workspaceDir, "restricted.txt")
77+
fs.writeFileSync(restricted, "x")
78+
79+
// Simulate realpath failing with EACCES (e.g. a symlink whose target has
80+
// restricted permissions). The path lexically lives inside the workspace, but
81+
// an unresolvable symlink must be treated as outside, not silently allowed.
82+
const spy = vi.spyOn(fs.realpathSync, "native").mockImplementation(() => {
83+
const err: NodeJS.ErrnoException = new Error("permission denied")
84+
err.code = "EACCES"
85+
throw err
86+
})
87+
88+
try {
89+
expect(isPathOutsideWorkspace(restricted)).toBe(true)
90+
} finally {
91+
spy.mockRestore()
92+
}
93+
})
94+
7595
it("returns true when there are no workspace folders", () => {
7696
mockWorkspace.folders = []
7797
expect(isPathOutsideWorkspace(path.join(workspaceDir, "file.ts"))).toBe(true)

src/utils/pathUtils.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,24 @@ import * as vscode from "vscode"
22
import * as path from "path"
33
import * as fs from "fs"
44

5+
/** Narrow an unknown error to a Node errno exception with the given `code`. */
6+
function isErrnoException(err: unknown, code: string): boolean {
7+
return err instanceof Error && (err as NodeJS.ErrnoException).code === code
8+
}
9+
510
/**
611
* Resolves a path to its canonical form, following symlinks.
712
*
813
* If the path does not exist yet (e.g. a file that is about to be created), the
914
* realpath of the nearest existing ancestor is resolved and the remaining
1015
* segments are re-appended. This ensures a symlink anywhere along the path is
1116
* still followed, while paths that don't exist yet can still be evaluated.
17+
*
18+
* Only `ENOENT` (a not-yet-existing segment) triggers the walk-up. Any other
19+
* error — e.g. `EACCES` on a symlink whose target has restricted permissions —
20+
* is re-thrown rather than swallowed: silently walking up would mask the symlink
21+
* and could let an out-of-workspace target look "inside". Callers performing a
22+
* security check are expected to fail closed on a thrown error. See issue #169.
1223
*/
1324
function realPathOrNearest(target: string): string {
1425
let current = path.resolve(target)
@@ -19,7 +30,13 @@ function realPathOrNearest(target: string): string {
1930
try {
2031
const resolved = fs.realpathSync.native(current)
2132
return trailing.length > 0 ? path.join(resolved, ...trailing.reverse()) : resolved
22-
} catch {
33+
} catch (err) {
34+
if (!isErrnoException(err, "ENOENT")) {
35+
// Non-ENOENT (e.g. EACCES): don't mask it with a walk-up — propagate so the
36+
// caller's security check can fail closed instead of falling through to the
37+
// lexical path.
38+
throw err
39+
}
2340
const parent = path.dirname(current)
2441
if (parent === current) {
2542
// Reached the root without finding an existing path; fall back to the
@@ -47,12 +64,25 @@ export function isPathOutsideWorkspace(filePath: string): boolean {
4764
// workspace but points outside it is correctly treated as outside. Without
4865
// this, the out-of-workspace read protection was trivially bypassed by
4966
// symlinking to a file outside the workspace. See issue #169.
50-
const absolutePath = realPathOrNearest(filePath)
67+
let absolutePath: string
68+
try {
69+
absolutePath = realPathOrNearest(filePath)
70+
} catch {
71+
// Could not safely resolve the target (e.g. EACCES on a symlink). Fail closed:
72+
// treat it as outside the workspace rather than risk a false "inside".
73+
return true
74+
}
5175

5276
// Check if the path is within any workspace folder
5377
return !vscode.workspace.workspaceFolders.some((folder) => {
5478
// Resolve the workspace folder too, in case it is itself reached via a symlink.
55-
const folderPath = realPathOrNearest(folder.uri.fsPath)
79+
let folderPath: string
80+
try {
81+
folderPath = realPathOrNearest(folder.uri.fsPath)
82+
} catch {
83+
// Can't resolve this folder safely; it can't be used to prove containment.
84+
return false
85+
}
5686
// Path is inside a workspace if it equals the workspace path or is a subfolder
5787
return absolutePath === folderPath || absolutePath.startsWith(folderPath + path.sep)
5888
})

0 commit comments

Comments
 (0)