From 3cf0fb26f9b7c2faabc2a4e040c0d472efe6ef09 Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:41:44 +0200 Subject: [PATCH 1/2] fix(storage): guard destructive directory moves and deletion --- electron/main/ipc-handlers.ts | 22 +- electron/main/storage-directory-guard.test.ts | 172 +++++++++++++++ electron/main/storage-directory-guard.ts | 199 ++++++++++++++++++ package.json | 2 +- 4 files changed, 377 insertions(+), 18 deletions(-) create mode 100644 electron/main/storage-directory-guard.test.ts create mode 100644 electron/main/storage-directory-guard.ts diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index e9cac040..f6243967 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -42,6 +42,7 @@ import { weightStorageHasLocalData, } from './model-sources' import { getSettings, setSettings } from './settings-store' +import { deleteStorageDirectory, moveStorageDirectory } from './storage-directory-guard' import { checkSetupNeeded, markSetupDone, runFullSetup, getVenvPythonExe, ensureSslPatch } from './python-setup' import { logger } from './logger' import { getProcessRunner, getPythonProcessRunner, getExtPythonExe, terminateProcessRunner, terminateAllProcessRunners } from './process-runner' @@ -1091,9 +1092,8 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe ipcMain.handle('fs:moveDirectory', async (_, { src, dest }: { src: string; dest: string }) => { try { - await mkdir(dest, { recursive: true }) - await cp(src, dest, { recursive: true }) - await rmAsync(src, { recursive: true, force: true }) + const userData = app.getPath('userData') + await moveStorageDirectory(src, dest, getSettings(userData), userData, { appDir: app.getAppPath() }) return { success: true } } catch (err) { return { success: false, error: String(err) } @@ -1101,21 +1101,9 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe }) ipcMain.handle('fs:deleteDirectory', async (_, dirPath: string) => { - const userData = app.getPath('userData') - const settings = getSettings(userData) - const allowedRoots = [ - settings.modelsDir, - settings.workspaceDir, - settings.extensionsDir, - join(userData, 'gen-cache'), - ] - const resolved = join(dirPath) - const isAllowed = allowedRoots.some((root) => resolved.startsWith(root)) - if (!isAllowed) { - return { success: false, error: 'Path is outside allowed directories' } - } try { - await rmAsync(resolved, { recursive: true, force: true }) + const userData = app.getPath('userData') + await deleteStorageDirectory(dirPath, getSettings(userData), userData, { appDir: app.getAppPath() }) return { success: true } } catch (err) { return { success: false, error: String(err) } diff --git a/electron/main/storage-directory-guard.test.ts b/electron/main/storage-directory-guard.test.ts new file mode 100644 index 00000000..d40aa3fa --- /dev/null +++ b/electron/main/storage-directory-guard.test.ts @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { mkdtemp, mkdir, rm, symlink, writeFile, readFile, access } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + validateStorageDeletion, + validateStorageMove, + assertNoSymlinkAncestors, + assertEmptyDestination, + deleteStorageDirectory, + moveStorageDirectory, +} from './storage-directory-guard' + +const dirs = { + modelsDir: 'E:\\modly\\models', + workspaceDir: 'E:\\modly\\workspace', + workflowsDir: 'E:\\modly\\workflows', + extensionsDir: 'E:\\modly\\extensions', + dependenciesDir: 'E:\\modly\\dependencies', + agentDir: 'E:\\modly\\agent', +} +const home = 'C:\\Users\\Tester' +const userData = 'C:\\Users\\Tester\\AppData\\Roaming\\modly' +const opts = { platform: 'win32' as const, homeDir: home, appDir: 'C:\\Program Files\\Modly\\resources\\app.asar' } + +test('deletion permits an exact configured managed directory', () => { + assert.equal(validateStorageDeletion('e:\\MODLY\\models\\\\', dirs, userData, opts), dirs.modelsDir) + assert.equal(validateStorageDeletion(dirs.workflowsDir, dirs, userData, opts), dirs.workflowsDir) + assert.equal(validateStorageDeletion('E:\\modly\\workspace\\tmp', dirs, userData, opts), 'E:\\modly\\workspace\\tmp') +}) + +test('deletion rejects filesystem roots, shallow folders and relative paths', () => { + for (const bad of ['E:\\\\', 'C:\\\\', 'E:\\Photos', 'models', '.', '']) { + assert.throws(() => validateStorageDeletion(bad, { ...dirs, modelsDir: bad }, userData, opts)) + } + assert.throws(() => validateStorageDeletion('\\\\server\\share\\models', dirs, userData, opts)) +}) + +test('deletion requires an exact configured root or workspace/tmp (not a prefix)', () => { + for (const bad of [ + 'E:\\modly\\models-backup', 'E:\\modly\\models\\elsewhere', + 'E:\\modly\\workspace\\tmp-fake', 'E:\\modly\\workflows2', + 'E:\\elsewhere\\models', + ]) { + assert.throws(() => validateStorageDeletion(bad, dirs, userData, opts), /not an approved/i) + } +}) + +test('deletion rejects home, application and userData ancestors even if configured', () => { + for (const bad of [home, 'C:\\Users', 'C:\\Program Files', 'C:\\Users\\Tester\\AppData', userData]) { + assert.throws(() => validateStorageDeletion(bad, { ...dirs, modelsDir: bad }, userData, opts)) + } +}) + +test('deletion refuses one configured folder enclosing another', () => { + assert.throws(() => validateStorageDeletion(dirs.modelsDir, { + ...dirs, workspaceDir: 'E:\\modly\\models\\my-workspace', + }, userData, opts), /another configured/i) +}) + +test('move allows separate configured source and empty destination path', () => { + assert.deepEqual(validateStorageMove(dirs.modelsDir, 'D:\\modly-assets\\models', dirs, userData, opts), { + src: dirs.modelsDir, dest: 'D:\\modly-assets\\models', + }) +}) + +test('move rejects rogue sources, root, parent, child and overlapping destinations', () => { + const badSources = ['E:\\\\', home, 'E:\\modly\\models-other', 'E:\\modly\\models\\child'] + for (const bad of badSources) { + assert.throws(() => validateStorageMove(bad, 'D:\\modly\\models', dirs, userData, opts)) + } + for (const bad of [ + dirs.modelsDir, 'E:\\modly', 'E:\\modly\\models\\new', 'E:\\modly\\workflows', + 'E:\\\\', 'D:\\Backups', 'E:\\modly\\workspace\\inside', + ]) { + assert.throws(() => validateStorageMove(dirs.modelsDir, bad, dirs, userData, opts)) + } +}) + +test('POSIX root and relative path are rejected', () => { + const linuxDirs = { + modelsDir: '/home/tester/modly/models', + workspaceDir: '/home/tester/modly/workspace', + workflowsDir: '/home/tester/modly/workflows', + extensionsDir: '/home/tester/modly/extensions', + dependenciesDir: '/home/tester/modly/dependencies', + agentDir: '/home/tester/modly/agent', + } + const linuxOpts = { platform: 'linux' as const, homeDir: '/home/tester' } + assert.equal(validateStorageDeletion(linuxDirs.modelsDir, linuxDirs, '/home/tester/.config/modly', linuxOpts), linuxDirs.modelsDir) + assert.throws(() => validateStorageDeletion('/', { ...linuxDirs, modelsDir: '/' }, '/home/tester/.config/modly', linuxOpts)) + assert.throws(() => validateStorageMove('relative', '/tmp/safe/new', linuxDirs, '/home/tester/.config/modly', linuxOpts)) +}) + +test('destination must be empty, missing is fine', async () => { + const base = await mkdtemp(join(tmpdir(), 'modly-storage-')) + try { + await assertEmptyDestination(join(base, 'new')) + await mkdir(join(base, 'empty')) + await assertEmptyDestination(join(base, 'empty')) + await writeFile(join(base, 'empty', 'personal.txt'), 'do not overwrite') + await assert.rejects(() => assertEmptyDestination(join(base, 'empty')), /empty/i) + } finally { + await rm(base, { recursive: true, force: true }) + } +}) + +test('symlinked ancestors and target are refused', { skip: process.platform === 'win32' }, async () => { + const base = await mkdtemp(join(tmpdir(), 'modly-storage-link-')) + try { + await mkdir(join(base, 'real', 'models'), { recursive: true }) + await symlink(join(base, 'real'), join(base, 'alias'), 'dir') + await assertNoSymlinkAncestors(join(base, 'real', 'models')) + await assert.rejects(() => assertNoSymlinkAncestors(join(base, 'alias', 'models')), /symbolic link/i) + await assert.rejects(() => assertNoSymlinkAncestors(join(base, 'alias')), /symbolic link/i) + } finally { + await rm(base, { recursive: true, force: true }) + } +}) + +test('unsafe real deletion refuses a storage root and preserves personal files', async () => { + const base = await mkdtemp(join(tmpdir(), 'modly-real-delete-')) + const managed = join(base, 'models') + const personal = join(base, 'personal.txt') + const unixDirs = { + modelsDir: managed, workspaceDir: join(base, 'workspace'), + workflowsDir: join(base, 'workflows'), extensionsDir: join(base, 'extensions'), + dependenciesDir: join(base, 'dependencies'), agentDir: join(base, 'agent'), + } + const options = { platform: process.platform, homeDir: join(base, 'other-home') } + try { + await mkdir(managed) + await writeFile(personal, 'important') + await assert.rejects(() => deleteStorageDirectory(base, { ...unixDirs, modelsDir: base }, + join(base, 'userData'), options), /ancestor|another configured|approved/i) + assert.equal(await readFile(personal, 'utf8'), 'important') + await deleteStorageDirectory(managed, unixDirs, join(base, 'userData'), options) + await assert.rejects(() => access(managed)) + assert.equal(await readFile(personal, 'utf8'), 'important') + } finally { + await rm(base, { recursive: true, force: true }) + } +}) + +test('real move does not overwrite destination files or erase unrelated data', async () => { + const base = await mkdtemp(join(tmpdir(), 'modly-real-move-')) + const source = join(base, 'models') + const destination = join(base, 'migrated', 'models') + const unixDirs = { + modelsDir: source, workspaceDir: join(base, 'workspace'), + workflowsDir: join(base, 'workflows'), extensionsDir: join(base, 'extensions'), + dependenciesDir: join(base, 'dependencies'), agentDir: join(base, 'agent'), + } + const options = { platform: process.platform, homeDir: join(base, 'other-home') } + try { + await mkdir(source) + await mkdir(destination, { recursive: true }) + await writeFile(join(source, 'asset.glb'), 'model') + await writeFile(join(destination, 'personal.txt'), 'preserve') + await assert.rejects(() => moveStorageDirectory(source, destination, unixDirs, + join(base, 'userData'), options), /empty/i) + assert.equal(await readFile(join(destination, 'personal.txt'), 'utf8'), 'preserve') + assert.equal(await readFile(join(source, 'asset.glb'), 'utf8'), 'model') + await rm(destination, { recursive: true }) + await moveStorageDirectory(source, destination, unixDirs, join(base, 'userData'), options) + assert.equal(await readFile(join(destination, 'asset.glb'), 'utf8'), 'model') + await assert.rejects(() => access(source)) + } finally { + await rm(base, { recursive: true, force: true }) + } +}) diff --git a/electron/main/storage-directory-guard.ts b/electron/main/storage-directory-guard.ts new file mode 100644 index 00000000..fb1e9b91 --- /dev/null +++ b/electron/main/storage-directory-guard.ts @@ -0,0 +1,199 @@ +/** Fail-closed validation for settings-driven directory moves and deletions. */ +import { cp, lstat, mkdir, readdir, rm } from 'node:fs/promises' +import { homedir } from 'node:os' +import path from 'node:path' + +type StorageDirs = { + modelsDir: string + workspaceDir: string + workflowsDir: string + extensionsDir: string + dependenciesDir: string + agentDir: string +} + +type Options = { + platform?: NodeJS.Platform + homeDir?: string + appDir?: string +} + +type PathApi = typeof path.win32 + +function pathApi(options: Options): PathApi { + return options.platform === 'win32' || (!options.platform && process.platform === 'win32') + ? path.win32 + : path.posix +} + +function absoluteDirectory(value: string, api: PathApi): string { + if (typeof value !== 'string' || !value.trim() || value.includes('\0') || !api.isAbsolute(value)) { + throw new Error('Storage path must be a non-empty absolute path') + } + // Drive-relative, extended Windows and UNC paths need separate review. + if (api === path.win32 && !/^[a-z]:[\\/]/i.test(value)) { + throw new Error('Storage operations require a local drive-absolute path') + } + const resolved = api.normalize(value) + if (resolved === api.parse(resolved).root) { + throw new Error('Refusing a filesystem or volume root') + } + // Never recursively delete a bare top-level user folder, even if configured. + const pieces = resolved.slice(api.parse(resolved).root.length).split(api.sep).filter(Boolean) + if (pieces.length < 2) { + throw new Error('Storage directory must be at least two levels below its volume root') + } + return resolved +} + +function equalPath(api: PathApi, a: string, b: string): boolean { + return api.relative(a, b) === '' +} + +function sameOrInside(api: PathApi, parent: string, candidate: string): boolean { + const relative = api.relative(parent, candidate) + return relative === '' || (relative !== '..' && !relative.startsWith('..' + api.sep) && !api.isAbsolute(relative)) +} + +function storageEntries(dirs: StorageDirs): Array<[string, string]> { + return [ + ['models', dirs.modelsDir], + ['workspace', dirs.workspaceDir], + ['workflows', dirs.workflowsDir], + ['extensions', dirs.extensionsDir], + ['dependencies', dirs.dependenciesDir], + ['agent', dirs.agentDir], + ] +} + +function verifySource(source: string, userData: string, options: Options): string { + const api = pathApi(options) + const safe = absoluteDirectory(source, api) + const protectedRoots = [userData, options.homeDir ?? homedir(), options.appDir] + for (const item of protectedRoots) { + if (!item) continue + const protectedPath = api.normalize(item) + if (api.isAbsolute(protectedPath) && sameOrInside(api, safe, protectedPath)) { + throw new Error('Refusing to remove an application, home, or user-data ancestor') + } + } + return safe +} + +function verifyNoOtherStorageInside( + source: string, selectedKey: string, dirs: StorageDirs, options: Options, +): void { + const api = pathApi(options) + for (const [key, value] of storageEntries(dirs)) { + if (key === selectedKey || !value) continue + const other = api.normalize(value) + if (api.isAbsolute(other) && sameOrInside(api, source, other)) { + throw new Error('Refusing to remove another configured storage directory (' + key + ')') + } + } +} + +/** Only exact configured roots, plus the specific workspace/tmp cleanup, are deletable. */ +export function validateStorageDeletion( + directory: string, dirs: StorageDirs, userData: string, options: Options = {}, +): string { + const api = pathApi(options) + const target = absoluteDirectory(directory, api) + const roots: Array<[string, string]> = [ + ['models', dirs.modelsDir], + ['workspace', dirs.workspaceDir], + ['workflows', dirs.workflowsDir], + ['extensions', dirs.extensionsDir], + ['cache', api.join(userData, 'gen-cache')], + ] + const selected = roots.find(([, root]) => root && equalPath(api, api.normalize(root), target)) + if (selected) { + const safe = verifySource(selected[1], userData, options) + verifyNoOtherStorageInside(safe, selected[0], dirs, options) + return safe + } + const workspace = verifySource(dirs.workspaceDir, userData, options) + if (equalPath(api, target, api.join(workspace, 'tmp'))) { + verifyNoOtherStorageInside(target, 'workspace', dirs, options) + return target + } + throw new Error('Path is not an approved Modly storage directory') +} + +/** A folder move may delete its source: restrict source and destination equally. */ +export function validateStorageMove( + source: string, destination: string, dirs: StorageDirs, userData: string, options: Options = {}, +): { src: string; dest: string } { + const api = pathApi(options) + const src = verifySource(source, userData, options) + const selected = storageEntries(dirs).find(([key, val]) => + ['models', 'workspace', 'workflows'].includes(key) && equalPath(api, api.normalize(val), src)) + if (!selected) throw new Error('Source is not a configured Modly storage directory') + verifyNoOtherStorageInside(src, selected[0], dirs, options) + const dest = verifySource(destination, userData, options) + if (sameOrInside(api, src, dest) || sameOrInside(api, dest, src)) { + throw new Error('Source and destination must be separate, non-overlapping directories') + } + // Reject destinations overlapping ANY configured storage root. + for (const [, value] of storageEntries(dirs)) { + if (!value) continue + const other = api.normalize(value) + if (sameOrInside(api, other, dest) || sameOrInside(api, dest, other)) { + throw new Error('Destination overlaps configured storage') + } + } + return { src, dest } +} + +/** Reject symlinks or Windows junctions in existing path ancestors. */ +export async function assertNoSymlinkAncestors(directory: string): Promise { + const api = process.platform === 'win32' ? path.win32 : path.posix + const root = api.parse(directory).root + let current = root + for (const part of directory.slice(root.length).split(api.sep).filter(Boolean)) { + current = api.join(current, part) + try { + const info = await lstat(current) + if (info.isSymbolicLink()) throw new Error('Storage path contains a symbolic link: ' + current) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') break + throw error + } + } +} + +export async function assertEmptyDestination(directory: string): Promise { + try { + if ((await readdir(directory)).length > 0) { + throw new Error('Destination must be empty to avoid overwriting existing files') + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } +} + +/** Main-process IPC handlers must use guarded operations, never raw rm/cp. */ +export async function deleteStorageDirectory( + directory: string, dirs: StorageDirs, userData: string, options: Options = {}, +): Promise { + const safe = validateStorageDeletion(directory, dirs, userData, options) + await assertNoSymlinkAncestors(safe) + await rm(safe, { recursive: true, force: true }) +} + +export async function moveStorageDirectory( + source: string, destination: string, dirs: StorageDirs, userData: string, options: Options = {}, +): Promise { + const paths = validateStorageMove(source, destination, dirs, userData, options) + await assertNoSymlinkAncestors(paths.src) + await assertNoSymlinkAncestors(paths.dest) + await assertEmptyDestination(paths.dest) + await mkdir(paths.dest, { recursive: true }) + // Refuse races that add files to the destination after the emptiness check. + await cp(paths.src, paths.dest, { recursive: true, force: false, errorOnExist: true }) + // Re-validate before deleting the source. Never delete it after a copy error. + validateStorageMove(paths.src, paths.dest, dirs, userData, options) + await assertNoSymlinkAncestors(paths.src) + await rm(paths.src, { recursive: true, force: true }) +} diff --git a/package.json b/package.json index a63727a2..a17e0aa4 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "prepare-resources": "node scripts/download-python-embed.js", "test": "npm run test:py && npm run test:node", "test:py": "node scripts/run-pytests.mjs", - "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/preload/artifact-registry-preload.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", + "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/preload/artifact-registry-preload.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." From 6481e15c6dd87143fed62d77e91c1a6e769b5154 Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:07:59 +0200 Subject: [PATCH 2/2] fix(storage): copy safely into empty destination without EEXIST Confirmed by combined CI regression. Remove only an empty target with rmdir; never remove source on copy failure. --- electron/main/storage-directory-guard.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/electron/main/storage-directory-guard.ts b/electron/main/storage-directory-guard.ts index fb1e9b91..f5a63a8c 100644 --- a/electron/main/storage-directory-guard.ts +++ b/electron/main/storage-directory-guard.ts @@ -1,5 +1,5 @@ /** Fail-closed validation for settings-driven directory moves and deletions. */ -import { cp, lstat, mkdir, readdir, rm } from 'node:fs/promises' +import { cp, lstat, mkdir, readdir, rm, rmdir } from 'node:fs/promises' import { homedir } from 'node:os' import path from 'node:path' @@ -189,8 +189,16 @@ export async function moveStorageDirectory( await assertNoSymlinkAncestors(paths.src) await assertNoSymlinkAncestors(paths.dest) await assertEmptyDestination(paths.dest) - await mkdir(paths.dest, { recursive: true }) - // Refuse races that add files to the destination after the emptiness check. + // fs.cp with errorOnExist refuses even an EMPTY existing directory. Remove + // only the verified-empty destination, never the source, before copying. + // rmdir itself fails closed if a concurrent writer adds a file. + try { + await rmdir(paths.dest) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + } + await mkdir(path.dirname(paths.dest), { recursive: true }) + // With a missing destination, errorOnExist protects against unexpected files. await cp(paths.src, paths.dest, { recursive: true, force: false, errorOnExist: true }) // Re-validate before deleting the source. Never delete it after a copy error. validateStorageMove(paths.src, paths.dest, dirs, userData, options)