From 9373c1dbed7bbfd215682be40ad2d6f8d21b18d7 Mon Sep 17 00:00:00 2001 From: Yohanes Date: Fri, 28 Aug 2026 16:16:56 +0800 Subject: [PATCH] fix(filesystem): allow recursive directory creation --- src/filesystem/__tests__/lib.test.ts | 17 ++++++++------- src/filesystem/lib.ts | 32 ++++++++++++++++++---------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index 1da741f0b0..32b6c83f7e 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -190,21 +190,22 @@ describe('Lib Functions', () => { expect(result).toBe(path.resolve(newFilePath)); }); - it('rejects when parent directory does not exist', async () => { + it('walks up to the nearest existing ancestor for nested paths', async () => { const newFilePath = process.platform === 'win32' ? 'C:\\Users\\test\\nonexistent\\newfile.txt' : '/home/user/nonexistent/newfile.txt'; - - // Create errors with the ENOENT code + + // The target and its immediate parent are absent; the allowed root exists. const enoentError1 = new Error('ENOENT') as NodeJS.ErrnoException; enoentError1.code = 'ENOENT'; const enoentError2 = new Error('ENOENT') as NodeJS.ErrnoException; enoentError2.code = 'ENOENT'; - + const existingRoot = process.platform === 'win32' ? 'C:\\Users\\test' : '/home/user'; + mockFs.realpath .mockRejectedValueOnce(enoentError1) - .mockRejectedValueOnce(enoentError2); - - await expect(validatePath(newFilePath)) - .rejects.toThrow('Parent directory does not exist'); + .mockRejectedValueOnce(enoentError2) + .mockResolvedValueOnce(existingRoot); + + await expect(validatePath(newFilePath)).resolves.toBe(path.resolve(newFilePath)); }); it('resolves relative paths against allowed directories instead of process.cwd()', async () => { diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index a1c6f04b67..82bd5cbd9b 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -120,19 +120,29 @@ export async function validatePath(requestedPath: string): Promise { } return realPath; } catch (error) { - // Security: For new files that don't exist yet, verify parent directory - // This ensures we can't create files in unauthorized locations + // Security: For new files/directories, resolve the nearest existing + // ancestor. This permits mkdir({ recursive: true }) while still checking + // the real path of an existing directory for symlink escapes. if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - const parentDir = path.dirname(absolute); - try { - const realParentPath = await fs.realpath(parentDir); - const normalizedParent = normalizePath(realParentPath); - if (!isPathWithinAllowedDirectories(normalizedParent, allowedDirectories)) { - throw new Error(`Access denied - parent directory outside allowed directories: ${realParentPath} not in ${allowedDirectories.join(', ')}`); + let existingAncestor = path.dirname(absolute); + while (true) { + try { + const realAncestorPath = await fs.realpath(existingAncestor); + const normalizedAncestor = normalizePath(realAncestorPath); + if (!isPathWithinAllowedDirectories(normalizedAncestor, allowedDirectories)) { + throw new Error(`Access denied - parent directory outside allowed directories: ${realAncestorPath} not in ${allowedDirectories.join(', ')}`); + } + return absolute; + } catch (ancestorError) { + if ((ancestorError as NodeJS.ErrnoException).code !== 'ENOENT') { + throw ancestorError; + } } - return absolute; - } catch { - throw new Error(`Parent directory does not exist: ${parentDir}`); + const parent = path.dirname(existingAncestor); + if (parent === existingAncestor) { + throw new Error(`Parent directory does not exist: ${existingAncestor}`); + } + existingAncestor = parent; } } throw error;