Skip to content

Commit c70d5bf

Browse files
fix(sdk): detect a directory swapped out from under list_directory
Approving the path and reading it are two separate lookups, so the directory the boundary check approved is not necessarily the one readdir opens: swap a path component for a symlink pointing outside the project in between and the listing comes back from wherever the swap pointed, having passed the check. Node has no readdir on a descriptor, so the read cannot be pinned to the inode that was approved. Pin identity around it instead - the directory that was approved, the one that was read, and the one still at that path afterwards must all be the same inode, and the path must still resolve inside the project. An attacker who restores the path before the recheck still wins, so this narrows the window rather than closing it; the comment says so rather than implying the check is airtight. Two tests cover it: a directory that changes inode across the read, and a path that starts resolving outside the project. Both fail without this change - the second one returns a listing from outside the project.
1 parent 6fe157d commit c70d5bf

2 files changed

Lines changed: 116 additions & 4 deletions

File tree

‎sdk/src/__tests__/list-directory.test.ts‎

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,19 @@ import path from 'path'
55
import { listDirectory } from '../tools/list-directory'
66

77
import type { CodebuffFileSystem } from '@codebuff/common/types/filesystem'
8-
import type { Dirent, PathLike } from 'node:fs'
8+
import type { Dirent, PathLike, Stats } from 'node:fs'
99

1010
const PROJECT_ROOT = path.resolve('workspace', 'project')
1111

12-
function createFs(realpaths: Record<string, string>) {
12+
function createFs(
13+
realpaths: Record<string, string>,
14+
options: {
15+
/** Called for each stat, so a test can make the directory change identity. */
16+
identities?: Array<{ dev: number; ino: number }>
17+
/** Realpath answers to use once the listing has been read. */
18+
realpathsAfterRead?: Record<string, string>
19+
} = {},
20+
) {
1321
const readdir = mock(async (_path: PathLike) => {
1422
return [
1523
{
@@ -20,15 +28,38 @@ function createFs(realpaths: Record<string, string>) {
2028
] as Dirent[]
2129
})
2230

31+
const identities = options.identities ?? []
32+
let statCalls = 0
33+
const stat = mock(async (_path: PathLike) => {
34+
const identity = identities[statCalls] ?? { dev: 1, ino: 1 }
35+
statCalls += 1
36+
return identity as unknown as Stats
37+
})
38+
39+
let listed = false
40+
readdir.mockImplementation(async (_path: PathLike) => {
41+
listed = true
42+
return [
43+
{
44+
name: 'index.ts',
45+
isDirectory: () => false,
46+
isFile: () => true,
47+
},
48+
] as Dirent[]
49+
})
50+
2351
const fs = {
2452
realpath: mock(async (path: PathLike) => {
2553
const pathString = String(path)
26-
return realpaths[pathString] ?? pathString
54+
const table =
55+
listed && options.realpathsAfterRead ? options.realpathsAfterRead : realpaths
56+
return table[pathString] ?? realpaths[pathString] ?? pathString
2757
}),
2858
readdir,
59+
stat,
2960
} as unknown as CodebuffFileSystem
3061

31-
return { fs, readdir }
62+
return { fs, readdir, stat }
3263
}
3364

3465
describe('listDirectory', () => {
@@ -193,6 +224,55 @@ describe('listDirectory', () => {
193224
expect(readdir).not.toHaveBeenCalled()
194225
})
195226

227+
it('refuses a listing whose directory was swapped while it was read', async () => {
228+
// The check approved one inode; by the time the read finished the path was a
229+
// different one. Returning that listing is the escape the check exists to stop.
230+
const { fs } = createFs(
231+
{ [PROJECT_ROOT]: PROJECT_ROOT },
232+
{
233+
identities: [
234+
{ dev: 1, ino: 1 },
235+
{ dev: 1, ino: 2 },
236+
],
237+
},
238+
)
239+
240+
const result = await listDirectory({
241+
directoryPath: '.',
242+
projectPath: PROJECT_ROOT,
243+
fs,
244+
})
245+
246+
expect(result[0]).toEqual({
247+
type: 'json',
248+
value: {
249+
errorMessage: `Invalid path: Path '.' changed while it was being read.`,
250+
},
251+
})
252+
})
253+
254+
it('refuses a listing whose path started resolving outside the project', async () => {
255+
const childPath = path.join(PROJECT_ROOT, 'src')
256+
const outsidePath = path.resolve('workspace', 'other', 'src')
257+
const { fs } = createFs(
258+
{ [PROJECT_ROOT]: PROJECT_ROOT, [childPath]: childPath },
259+
{ realpathsAfterRead: { [childPath]: outsidePath } },
260+
)
261+
262+
const result = await listDirectory({
263+
directoryPath: 'src',
264+
projectPath: PROJECT_ROOT,
265+
fs,
266+
})
267+
268+
expect(result[0]).toEqual({
269+
type: 'json',
270+
value: {
271+
errorMessage: `Invalid path: Path 'src' changed while it was being read.`,
272+
},
273+
})
274+
})
275+
196276
it('allows a symlink that resolves inside the project', async () => {
197277
const symlinkPath = path.join(PROJECT_ROOT, 'link')
198278
const realTarget = path.join(PROJECT_ROOT, 'src')

‎sdk/src/tools/list-directory.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,42 @@ export async function listDirectory(params: {
2828
]
2929
}
3030

31+
// Checking the path and then reading it are two separate lookups, so the
32+
// directory the check approved is not necessarily the one that gets read: a
33+
// component of the path can be swapped for a symlink pointing outside the
34+
// project in between, and the listing would come back from wherever the swap
35+
// pointed. Node has no readdir-on-a-descriptor, so the read cannot be pinned
36+
// to the inode that was approved. Pinning identity around it is what is
37+
// available: the directory that was approved, the one that was read, and the
38+
// one still at that path afterwards must all be the same inode, and the path
39+
// must still resolve inside the project. That does not make the swap
40+
// impossible - an attacker who restores the path before the recheck still
41+
// wins - but it turns the common case from a silent escape into a refusal.
42+
const identityBefore = await fs.stat(realResolvedPath)
43+
3144
const entries = await fs.readdir(realResolvedPath, {
3245
withFileTypes: true,
3346
})
3447

48+
const identityAfter = await fs.stat(realResolvedPath)
49+
const realResolvedPathAfter = await fs.realpath(realResolvedPath)
50+
51+
if (
52+
identityAfter.dev !== identityBefore.dev ||
53+
identityAfter.ino !== identityBefore.ino ||
54+
realResolvedPathAfter !== realResolvedPath ||
55+
!isPathInside(realProjectRoot, realResolvedPathAfter)
56+
) {
57+
return [
58+
{
59+
type: 'json',
60+
value: {
61+
errorMessage: `Invalid path: Path '${directoryPath}' changed while it was being read.`,
62+
},
63+
},
64+
]
65+
}
66+
3567
const files: string[] = []
3668
const directories: string[] = []
3769

0 commit comments

Comments
 (0)