From a5c813aa8fa2aabfc2a9a800659550243e6d0ba4 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 17 Sep 2026 20:00:26 -0700 Subject: [PATCH] Fix macOS Unicode workspace path authorization --- src/main/ipc/filesystem-auth.test.ts | 47 ++++++++++++++++++++- src/main/ipc/filesystem-path-containment.ts | 14 ++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index fa82b7a652c..cf6a23f9f6f 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -1,5 +1,5 @@ import type * as NodePath from 'node:path' -import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -365,6 +365,37 @@ describe('filesystem-auth path containment', () => { } ) + it.skipIf(process.platform !== 'darwin')( + 'reads Korean folder workspace files across NFC/NFD spellings and rejects symlink escapes', + async () => { + const scratch = await mkdtemp(join(await realpath(tmpdir()), 'orca-korean-auth-')) + try { + const folderPath = join(scratch, '테스트프로젝트'.normalize('NFC')) + await mkdir(folderPath) + await writeFile(join(folderPath, 'test.txt'), 'Korean workspace proof') + const store = makeStore([], { + folderWorkspaces: [makeFolderWorkspace({ folderPath })] + }) + const requestedPath = join(folderPath.normalize('NFD'), 'test.txt') + expect(await readFile(await resolveAuthorizedPath(requestedPath, store), 'utf8')).toBe( + 'Korean workspace proof' + ) + await expect( + resolveAuthorizedPath(join(folderPath.normalize('NFD'), 'new', 'file.txt'), store) + ).resolves.toBe(join(await realpath(folderPath), 'new', 'file.txt')) + const outside = join(scratch, 'outside') + await mkdir(outside) + await writeFile(join(outside, 'secret.txt'), 'outside') + await symlink(outside, join(folderPath, 'escape')) + await expect( + resolveAuthorizedPath(join(folderPath.normalize('NFD'), 'escape', 'secret.txt'), store) + ).rejects.toThrow('Access denied') + } finally { + await rm(scratch, { 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') @@ -372,6 +403,20 @@ describe('filesystem-auth path containment', () => { expect(isDescendantOrEqual(child, root)).toBe(true) }) + it('treats NFC and NFD macOS path spellings as the same descendant', () => { + const root = '/workspace/테스트프로젝트'.normalize('NFC') + const child = `${'/workspace/테스트프로젝트'.normalize('NFD')}/test.txt` + + expect(isDescendantOrEqual(child, root, 'darwin')).toBe(true) + // Linux and SSH filesystems may distinguish these byte spellings. + expect(isDescendantOrEqual(child, root, 'linux')).toBe(false) + expect(isDescendantOrEqual(child, root, 'win32')).toBe(false) + expect(isDescendantOrEqual(root.normalize('NFD'), root, 'darwin')).toBe(true) + expect(isDescendantOrEqual(`${root.normalize('NFD')}-other/test.txt`, root, 'darwin')).toBe( + false + ) + }) + it('allows git-relative files under dotdot-prefixed child directories', () => { expect(validateGitRelativeFilePath(resolve('/workspace/repo'), '..fixtures/file.ts')).toBe( join('..fixtures', 'file.ts') diff --git a/src/main/ipc/filesystem-path-containment.ts b/src/main/ipc/filesystem-path-containment.ts index 32d1c00f0e1..c916244c584 100644 --- a/src/main/ipc/filesystem-path-containment.ts +++ b/src/main/ipc/filesystem-path-containment.ts @@ -5,11 +5,19 @@ import { realpath } from 'node:fs/promises' * Check whether resolvedTarget is equal to or a descendant of resolvedBase. * Uses relative() so it works with both `/` (Unix) and `\` (Windows) separators. */ -export function isDescendantOrEqual(resolvedTarget: string, resolvedBase: string): boolean { - if (resolvedTarget === resolvedBase) { +export function isDescendantOrEqual( + resolvedTarget: string, + resolvedBase: string, + platform: NodeJS.Platform = process.platform +): boolean { + // APFS commonly returns decomposed names while workspace state may contain composed names. + // Keep byte-distinct Linux/SSH paths distinct; only macOS treats these forms as the same name. + const target = platform === 'darwin' ? resolvedTarget.normalize('NFC') : resolvedTarget + const base = platform === 'darwin' ? resolvedBase.normalize('NFC') : resolvedBase + if (target === base) { return true } - const rel = relative(resolvedBase, resolvedTarget) + const rel = relative(base, target) // Security: reject "..", "../…" or an absolute rel — on Windows relative() returns absolute across drives, which would bypass drive-traversal checks. // Use isAbsolute, not rejoin+compare: Windows path.relative() ignores drive/root casing, so rejoining would deny valid c:\repo under C:\Repo. return rel !== '' && !(rel === '..' || rel.startsWith(`..${sep}`)) && !isAbsolute(rel)