mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Fix macOS Unicode workspace path authorization
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user