Files
orca/src/main/ipc/filesystem-auth.test.ts
T
Brennan Benson 9db7b8b6b2 fix(files): never write through a dangling symlink (#28385)
* fix(files): never write through a dangling symlink

A path that doesn't exist yet was authorized by walking up to the nearest
existing folder. A dangling symlink fails realpath with ENOENT, so the walk
stepped over it and approved the link's own path; the write then followed
the link and created its target outside the allowed folders. The walk now
continues from the link's target, and returns that real destination.

* fix(files): refuse any dangling symlink in the missing-path walk

Following a dangling link by hand resolved relative targets against the
lexical parent, not the link's real folder: through a folder link the walk
could loop forever or authorize the wrong file. Refuse instead: realpath
can't say where a dangling link leads, but a write would follow it.

* test(files): drop the loop case that duplicated the dangling-link refusal
2026-10-13 19:08:15 -07:00

425 lines
15 KiB
TypeScript

import type * as NodePath from 'node:path'
import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import type * as RepoWorktrees from '../repo-worktrees'
import { listRepoWorktreeGraph } from '../repo-worktrees'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { Repo } from '../../shared/repo-types'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import { resolveAuthorizedPath } from './filesystem-auth'
import { isDescendantOrEqual, validateGitRelativeFilePath } from './filesystem-path-containment'
import {
__resetCreatedWorktreeRootsForTests,
invalidateAuthorizedRootsCache,
rebuildAuthorizedRootsCache,
registerCreatedWorktreeRoot,
resolveRegisteredWorktreePath
} from './registered-worktree-roots-cache'
vi.mock('../repo-worktrees', async () => {
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
return {
...actual,
listRepoWorktreeGraph: vi.fn()
}
})
const LARGE_WORKTREE_ROOT_COUNT = 150_000
const repo: Repo = {
id: 'repo-1',
path: '/repos/app',
displayName: 'app',
badgeColor: '#000000',
addedAt: 1,
kind: 'git'
}
function makeProjectGroup(overrides: Partial<ProjectGroup> = {}): ProjectGroup {
return {
id: 'group-1',
name: 'Workspace',
parentPath: '/folders/workspace',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
return {
id: 'folder-workspace-1',
projectGroupId: 'group-1',
name: 'Feature',
folderPath: '/folders/workspace',
comment: '',
linkedTask: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeStore(
repos: Repo[] = [repo],
options: {
projectGroups?: ProjectGroup[]
folderWorkspaces?: FolderWorkspace[]
} = {}
): Store {
return {
getRepos: () => repos,
getProjectGroups: () => options.projectGroups ?? [],
getFolderWorkspaces: () => options.folderWorkspaces ?? [],
getSettings: () => ({})
} as unknown as Store
}
describe('filesystem auth worktree roots', () => {
beforeEach(() => {
invalidateAuthorizedRootsCache()
__resetCreatedWorktreeRootsForTests()
vi.mocked(listRepoWorktreeGraph).mockReset()
})
it('rebuilds the authorized roots cache for large worktree lists', async () => {
const worktrees: GitWorktreeInfo[] = Array.from(
{ length: LARGE_WORKTREE_ROOT_COUNT },
(_, index) => ({
path: `/linked/worktree-${index}`,
head: '',
branch: `refs/heads/generated-${index}`,
isBare: false,
isMainWorktree: false
})
)
vi.mocked(listRepoWorktreeGraph).mockResolvedValue(worktrees)
const store = makeStore()
await rebuildAuthorizedRootsCache(store)
const lastWorktreePath = `/linked/worktree-${LARGE_WORKTREE_ROOT_COUNT - 1}`
await expect(resolveRegisteredWorktreePath(lastWorktreePath, store)).resolves.toBe(
resolve(lastWorktreePath)
)
expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(1)
})
it("keeps a repo's roots when its listing fails mid-rebuild", async () => {
// Why: the rebuild runs while Git is still broken, and dropping the roots would revoke
// a worktree a create just recovered without a listing (#16520).
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, '/linked/recovered')
vi.mocked(listRepoWorktreeGraph).mockRejectedValue(new Error('git worktree list failed.'))
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath('/linked/recovered', store)).resolves.toBe(
resolve('/linked/recovered')
)
})
it('keeps a recovered root when a healthy rebuild still cannot list it', async () => {
// Why: the rebuild recomputes from the very listing that omitted the row, so replacing the cache
// would re-deny the worktree the create just recovered (#16520).
const scratch = await mkdtemp(join(await realpath(tmpdir()), 'orca-recovered-'))
const recovered = join(scratch, 'feature')
await mkdir(recovered)
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, recovered)
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([])
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath(recovered, store)).resolves.toBe(resolve(recovered))
await rm(scratch, { recursive: true, force: true })
})
it('survives a rebuild that was already in flight when the create recovered', async () => {
const scratch = await mkdtemp(join(await realpath(tmpdir()), 'orca-recovered-race-'))
const recovered = join(scratch, 'feature')
await mkdir(recovered)
const store = makeStore()
// Register mid-listing: the rebuild's own result was computed before this worktree existed.
vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => {
registerCreatedWorktreeRoot(store, repo.id, recovered)
return []
})
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath(recovered, store)).resolves.toBe(resolve(recovered))
await rm(scratch, { recursive: true, force: true })
})
it('retires a recovered root once the listing can see it again', async () => {
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, '/linked/feature')
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([
{
path: '/linked/feature',
head: '',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
])
await rebuildAuthorizedRootsCache(store)
// Still authorized, but now from the listing itself; a later listing that drops it is authoritative.
await expect(resolveRegisteredWorktreePath('/linked/feature', store)).resolves.toBe(
resolve('/linked/feature')
)
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([])
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath('/linked/feature', store)).rejects.toThrow(
'Access denied'
)
})
it('bounds concurrent repo probes while rebuilding authorized roots', async () => {
const repos = Array.from({ length: 20 }, (_, index) => ({
...repo,
id: `repo-${index}`,
path: `/repos/app-${index}`
}))
let active = 0
let maxActive = 0
vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => {
active += 1
maxActive = Math.max(maxActive, active)
await new Promise((resolve) => setTimeout(resolve, 1))
active -= 1
return []
})
await rebuildAuthorizedRootsCache(makeStore(repos))
expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(repos.length)
expect(maxActive).toBeLessThanOrEqual(8)
})
})
describe('filesystem-auth path containment', () => {
it('authorizes missing nested descendants under an allowed repo', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-missing-'))
try {
const repoPath = join(tempRoot, 'repo')
await mkdir(repoPath)
const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }])
const targetPath = join(repoPath, 'new', 'nested', 'file.ts')
await expect(resolveAuthorizedPath(targetPath, store)).resolves.toBe(
join(await realpath(repoPath), 'new', 'nested', 'file.ts')
)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('authorizes local folder workspace roots outside child repo roots', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-folder-workspace-'))
try {
const folderPath = join(tempRoot, 'platform')
const repoPath = join(folderPath, 'web')
await mkdir(repoPath, { recursive: true })
const projectGroup = makeProjectGroup({ parentPath: folderPath })
const folderWorkspace = makeFolderWorkspace({ folderPath, projectGroupId: projectGroup.id })
const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }], {
projectGroups: [projectGroup],
folderWorkspaces: [folderWorkspace]
})
await expect(resolveAuthorizedPath(folderPath, store)).resolves.toBe(
await realpath(folderPath)
)
await expect(resolveAuthorizedPath(join(folderPath, 'notes.md'), store)).resolves.toBe(
join(await realpath(folderPath), 'notes.md')
)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('authorizes local folder-backed project group roots outside child repo roots', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-project-group-'))
try {
const folderPath = join(tempRoot, 'platform')
const repoPath = join(folderPath, 'web')
await mkdir(repoPath, { recursive: true })
const projectGroup = makeProjectGroup({ parentPath: folderPath })
const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }], {
projectGroups: [projectGroup]
})
await expect(resolveAuthorizedPath(folderPath, store)).resolves.toBe(
await realpath(folderPath)
)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('does not authorize SSH-only folder workspace roots as local paths', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-folder-workspace-'))
try {
const folderPath = join(tempRoot, 'remote-platform')
const repoPath = join(folderPath, 'web')
await mkdir(repoPath, { recursive: true })
const projectGroup = makeProjectGroup({ parentPath: folderPath })
const folderWorkspace = makeFolderWorkspace({ folderPath, projectGroupId: projectGroup.id })
const store = makeStore(
[{ ...repo, id: 'repo-temp', path: repoPath, connectionId: 'ssh-1' }],
{
projectGroups: [projectGroup],
folderWorkspaces: [folderWorkspace]
}
)
await expect(resolveAuthorizedPath(folderPath, store)).rejects.toThrow('Access denied')
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('does not authorize repo-less SSH-provenance folder roots as local paths', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-folder-provenance-'))
try {
const folderPath = join(tempRoot, 'remote-platform')
await mkdir(folderPath, { recursive: true })
const projectGroup = makeProjectGroup({ parentPath: folderPath, connectionId: 'ssh-1' })
const folderWorkspace = makeFolderWorkspace({
folderPath,
projectGroupId: projectGroup.id,
connectionId: 'ssh-1'
})
const store = makeStore([], {
projectGroups: [projectGroup],
folderWorkspaces: [folderWorkspace]
})
await expect(resolveAuthorizedPath(folderPath, store)).rejects.toThrow('Access denied')
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('does not authorize SSH-only folder-backed project group roots as local paths', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-project-group-'))
try {
const folderPath = join(tempRoot, 'remote-platform')
const repoPath = join(folderPath, 'web')
await mkdir(repoPath, { recursive: true })
const projectGroup = makeProjectGroup({ parentPath: folderPath })
const store = makeStore(
[{ ...repo, id: 'repo-temp', path: repoPath, connectionId: 'ssh-1' }],
{
projectGroups: [projectGroup]
}
)
await expect(resolveAuthorizedPath(folderPath, store)).rejects.toThrow('Access denied')
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it.skipIf(process.platform === 'win32')(
'rejects paths through a symlink out of the repo, and any dangling symlink',
async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-symlink-'))
try {
const repoPath = join(tempRoot, 'repo')
const outsidePath = join(tempRoot, 'outside')
await mkdir(repoPath)
await mkdir(outsidePath)
await symlink(outsidePath, join(repoPath, 'linked-outside'), 'dir')
await symlink(join(outsidePath, 'new.txt'), join(repoPath, 'dangling.txt'))
await symlink(join(repoPath, 'later.txt'), join(repoPath, 'inside.txt'))
const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }])
await expect(
resolveAuthorizedPath(join(repoPath, 'linked-outside', 'new', 'file.ts'), store)
).rejects.toThrow('Access denied')
// A write follows a dangling link wherever it points, so any dangling link is refused.
for (const name of ['dangling.txt', 'inside.txt']) {
await expect(resolveAuthorizedPath(join(repoPath, name), store)).rejects.toThrow(
'Access denied'
)
}
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
}
)
it('allows descendants whose path segment starts with dotdot characters', () => {
const root = resolve('/workspace/repo')
const child = resolve('/workspace/repo/..fixtures/file.ts')
expect(isDescendantOrEqual(child, root)).toBe(true)
})
it('allows git-relative files under dotdot-prefixed child directories', () => {
expect(validateGitRelativeFilePath(resolve('/workspace/repo'), '..fixtures/file.ts')).toBe(
join('..fixtures', 'file.ts')
)
})
it('still rejects parent-directory escapes', () => {
const root = resolve('/workspace/repo')
const outside = resolve('/workspace/repo/../other/file.ts')
expect(isDescendantOrEqual(outside, root)).toBe(false)
expect(() => validateGitRelativeFilePath(root, '../other/file.ts')).toThrow(
'Access denied: git file path escapes the selected worktree'
)
})
it('accepts Windows descendants when drive and root casing differ', async () => {
vi.resetModules()
vi.doMock('../repo-worktrees', () => ({
isRepoRoot: vi.fn(),
listRepoWorktreeGraph: vi.fn()
}))
vi.doMock('path', async () => {
const path = await vi.importActual<typeof NodePath>('node:path')
return {
...path.win32,
default: path.win32
}
})
try {
const { isDescendantOrEqual: isDescendantOrEqualWithWinPath } =
await import('./filesystem-path-containment')
expect(
isDescendantOrEqualWithWinPath(String.raw`c:\repo\src\app.ts`, String.raw`C:\Repo`)
).toBe(true)
expect(
isDescendantOrEqualWithWinPath(String.raw`D:\repo\src\app.ts`, String.raw`C:\Repo`)
).toBe(false)
} finally {
vi.doUnmock('path')
vi.doUnmock('../repo-worktrees')
vi.resetModules()
}
})
})