Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/filesystem/__tests__/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,23 @@ describe('Lib Functions', () => {
.rejects.toThrow('Access denied - path outside allowed directories');
});

it('allows paths whose canonical form is in an allowed directory', async () => {
const requestedPath = process.platform === 'win32'
? 'Y:\\project\\file.txt'
: '/mapped/project/file.txt';
const realPath = process.platform === 'win32'
? '\\\\nas\\share\\project\\file.txt'
: '/canonical/project/file.txt';
const allowedDir = process.platform === 'win32'
? '\\\\nas\\share\\project'
: '/canonical/project';

setAllowedDirectories([allowedDir]);
mockFs.realpath.mockResolvedValueOnce(realPath);

await expect(validatePath(requestedPath)).resolves.toBe(realPath);
});

it('handles non-existent files by checking parent directory', async () => {
const newFilePath = process.platform === 'win32' ? 'C:\\Users\\test\\newfile.txt' : '/home/user/newfile.txt';
const parentPath = process.platform === 'win32' ? 'C:\\Users\\test' : '/home/user';
Expand All @@ -188,6 +205,28 @@ describe('Lib Functions', () => {
expect(result).toBe(path.resolve(newFilePath));
});

it('allows new files whose canonical parent is in an allowed directory', async () => {
const newFilePath = process.platform === 'win32'
? 'Y:\\project\\newfile.txt'
: '/mapped/project/newfile.txt';
const realParentPath = process.platform === 'win32'
? '\\\\nas\\share\\project'
: '/canonical/project';
const allowedDir = process.platform === 'win32'
? '\\\\nas\\share\\project'
: '/canonical/project';
const enoentError = new Error('ENOENT') as NodeJS.ErrnoException;
enoentError.code = 'ENOENT';

setAllowedDirectories([allowedDir]);
mockFs.realpath
.mockRejectedValueOnce(enoentError)
.mockResolvedValueOnce(realParentPath);

const result = await validatePath(newFilePath);
expect(result).toBe(path.resolve(newFilePath));
});

it('rejects when parent directory does not exist', async () => {
const newFilePath = process.platform === 'win32' ? 'C:\\Users\\test\\nonexistent\\newfile.txt' : '/home/user/nonexistent/newfile.txt';

Expand Down
25 changes: 13 additions & 12 deletions src/filesystem/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,37 +103,38 @@ export async function validatePath(requestedPath: string): Promise<string> {
: resolveRelativePathAgainstAllowedDirectories(expandedPath);

const normalizedRequested = normalizePath(absolute);

// Security: Check if path is within allowed directories before any file operations
const isAllowed = isPathWithinAllowedDirectories(normalizedRequested, allowedDirectories);
if (!isAllowed) {
throw new Error(`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`);
}

// Security: Handle symlinks by checking their real path to prevent symlink attacks
// This prevents attackers from creating symlinks that point outside allowed directories
try {
const realPath = await fs.realpath(absolute);
const normalizedReal = normalizePath(realPath);
if (!isPathWithinAllowedDirectories(normalizedReal, allowedDirectories)) {
throw new Error(`Access denied - symlink target outside allowed directories: ${realPath} not in ${allowedDirectories.join(', ')}`);
const reason = isAllowed ? `symlink target outside allowed directories: ${realPath}` : `path outside allowed directories: ${absolute}`;
throw new Error(`Access denied - ${reason} not in ${allowedDirectories.join(', ')}`);
}
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
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
const parentDir = path.dirname(absolute);
let realParentPath: string;
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(', ')}`);
}
return absolute;
realParentPath = await fs.realpath(parentDir);
} catch {
throw new Error(`Parent directory does not exist: ${parentDir}`);
}
const normalizedParent = normalizePath(realParentPath);
if (!isPathWithinAllowedDirectories(normalizedParent, allowedDirectories)) {
const reason = isAllowed ? `parent directory outside allowed directories: ${realParentPath}` : `path outside allowed directories: ${absolute}`;
throw new Error(`Access denied - ${reason} not in ${allowedDirectories.join(', ')}`);
}
return absolute;
}
if (!isAllowed) {
throw new Error(`Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}`);
}
throw error;
}
Expand Down
Loading