diff --git a/src/main/utils/pathValidation.ts b/src/main/utils/pathValidation.ts index 25d4841..1aab3a6 100644 --- a/src/main/utils/pathValidation.ts +++ b/src/main/utils/pathValidation.ts @@ -1,6 +1,6 @@ import { app } from 'electron' import { existsSync, realpathSync } from 'fs' -import { resolve, normalize } from 'path' +import { resolve, normalize, sep } from 'path' export const isPathAllowed = (filePath: string): boolean => { if (typeof filePath !== 'string' || !filePath) return false @@ -12,12 +12,13 @@ export const isPathAllowed = (filePath: string): boolean => { app.getPath('temp'), ] return allowedDirs.some(dir => { + const normalizedDir = resolve(normalize(dir)) try { - const realDir = realpathSync(dir) + const realDir = realpathSync(normalizedDir) const realPath = existsSync(resolved) ? realpathSync(resolved) : resolved - return realPath.startsWith(realDir + '/') + return realPath.startsWith(realDir + sep) } catch { - return resolved.startsWith(dir + '/') + return resolved.startsWith(normalizedDir + sep) } }) } diff --git a/src/tests/unit/main/pathValidation.test.ts b/src/tests/unit/main/pathValidation.test.ts index 1816216..dd0750f 100644 --- a/src/tests/unit/main/pathValidation.test.ts +++ b/src/tests/unit/main/pathValidation.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { resolve, normalize, sep } from 'path'; const { mockGetPath, mockExistsSync, mockRealpathSync } = vi.hoisted(() => ({ mockGetPath: vi.fn(), @@ -179,26 +180,30 @@ describe('isPathAllowed', () => { describe('symlink resolution via realpathSync', () => { it('should return true when realpath resolves to an allowed directory', () => { + const docsDir = resolve(normalize('/Users/testuser/Documents')); + const docsFile = resolve(normalize('/Users/testuser/Documents/file.txt')); + const symlinkDir = resolve(normalize('/Volumes/Data/Documents')); + const symlinkFile = resolve(normalize('/Volumes/Data/Documents/file.txt')); + mockExistsSync.mockReturnValue(true); mockRealpathSync.mockImplementation((p: string) => { - if (p === '/Users/testuser/Documents') return '/Volumes/Data/Documents'; - if (p === '/Users/testuser/Documents/file.txt') return '/Volumes/Data/Documents/file.txt'; + if (p === docsDir) return symlinkDir; + if (p === docsFile) return symlinkFile; return p; }); - // resolve(normalize(filePath)) = /Users/testuser/Documents/file.txt - // existsSync returns true, so realpathSync is called on the resolved path - // realpathSync('/Users/testuser/Documents') = /Volumes/Data/Documents - // realpathSync('/Users/testuser/Documents/file.txt') = /Volumes/Data/Documents/file.txt - // '/Volumes/Data/Documents/file.txt'.startsWith('/Volumes/Data/Documents/') = true expect(isPathAllowed('/Users/testuser/Documents/file.txt')).toBe(true); }); it('should return false when symlink resolves outside allowed directories', () => { + const docsDir = resolve(normalize('/Users/testuser/Documents')); + const evilLink = resolve(normalize('/Users/testuser/Documents/evil-link')); + const etcShadow = resolve(normalize('/etc/shadow')); + mockExistsSync.mockReturnValue(true); mockRealpathSync.mockImplementation((p: string) => { - if (p === '/Users/testuser/Documents') return '/Users/testuser/Documents'; - if (p === '/Users/testuser/Documents/evil-link') return '/etc/shadow'; + if (p === docsDir) return docsDir; + if (p === evilLink) return etcShadow; return p; });