fix: validate clipboard staging permissions

This commit is contained in:
OrcaWin
2026-08-07 00:57:05 -07:00
parent 42551fde15
commit 1868e29ebc
3 changed files with 27 additions and 7 deletions
@@ -200,10 +200,10 @@ describe('registerClipboardHandlers', () => {
handleMock.mockReset()
spawnMock.mockClear()
childStdinEndMock.mockClear()
resolveAuthorizedPathMock.mockReset()
resolveAuthorizedPathMock.mockImplementation(async (path: string) => path)
resolveAuthorizedPathMock.mockReset().mockImplementation(async (path: string) => path)
fsAccessMock.mockReset().mockResolvedValue(undefined)
fsLstatMock.mockReset().mockResolvedValue({
mode: 0o700,
uid: typeof process.getuid === 'function' ? process.getuid() : 0,
isDirectory: () => true,
isSymbolicLink: () => false
@@ -215,12 +215,10 @@ describe('registerClipboardHandlers', () => {
async *[Symbol.asyncIterator]() {},
close: vi.fn().mockResolvedValue(undefined)
}))
fsRmMock.mockReset()
fsRmMock.mockResolvedValue(undefined)
fsRmMock.mockReset().mockResolvedValue(undefined)
fsWriteFileMock.mockReset()
fsOpenMock.mockReset()
fsStatMock.mockReset()
fsStatMock.mockResolvedValue({})
fsStatMock.mockReset().mockResolvedValue({})
clipboardReadTextMock.mockReset()
clipboardReadBufferMock.mockReset()
clipboardReadBufferMock.mockReturnValue(Buffer.alloc(0))
@@ -74,6 +74,7 @@ describe('cleanupExpiredRemoteClipboardFiles', () => {
vi.clearAllMocks()
accessMock.mockResolvedValue(undefined)
lstatMock.mockResolvedValue({
mode: 0o700,
uid: typeof process.getuid === 'function' ? process.getuid() : 0,
isDirectory: () => true,
isSymbolicLink: () => false
@@ -92,6 +93,7 @@ describe('cleanupExpiredRemoteClipboardFiles', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('scans only the owned staging root after legacy migration', async () => {
@@ -161,6 +163,7 @@ describe('cleanupExpiredRemoteClipboardFiles', () => {
it('rejects an unsafe staging-root symlink before scanning it', async () => {
lstatMock.mockResolvedValue({
mode: 0o700,
uid: typeof process.getuid === 'function' ? process.getuid() : 0,
isDirectory: () => false,
isSymbolicLink: () => true
@@ -177,6 +180,7 @@ describe('cleanupExpiredRemoteClipboardFiles', () => {
return
}
lstatMock.mockResolvedValue({
mode: 0o700,
uid: process.getuid() + 1,
isDirectory: () => true,
isSymbolicLink: () => false
@@ -187,6 +191,23 @@ describe('cleanupExpiredRemoteClipboardFiles', () => {
expect(opendirMock).not.toHaveBeenCalled()
})
it('rejects a POSIX staging root with insecure permissions', async () => {
const processWithUid = Object.assign(Object.create(process) as NodeJS.Process, {
getuid: () => 1000
})
vi.stubGlobal('process', processWithUid)
lstatMock.mockResolvedValue({
mode: 0o755,
uid: 1000,
isDirectory: () => true,
isSymbolicLink: () => false
})
await cleanupExpiredRemoteClipboardFiles(NOW_MS)
expect(opendirMock).not.toHaveBeenCalled()
})
it('migrates legacy directories once and records completion', async () => {
accessMock.mockRejectedValue(new Error('ENOENT'))
opendirMock.mockImplementation(async (targetPath: string) => {
@@ -99,7 +99,8 @@ async function ensureRemoteClipboardStagingRoot(): Promise<string> {
await mkdir(stagingRoot, { recursive: true, mode: 0o700 })
const rootStats = await lstat(stagingRoot)
const wrongOwner = typeof process.getuid === 'function' && rootStats.uid !== process.getuid()
if (!rootStats.isDirectory() || rootStats.isSymbolicLink() || wrongOwner) {
const wrongMode = typeof process.getuid === 'function' && (rootStats.mode & 0o777) !== 0o700
if (!rootStats.isDirectory() || rootStats.isSymbolicLink() || wrongOwner || wrongMode) {
throw new Error('Remote clipboard staging root is unsafe')
}
return stagingRoot