Skip to content

Commit bae6d12

Browse files
author
Chris Funderburg
committed
feat(folders): enhance folder move functionality with path validation
1 parent 2ed13cd commit bae6d12

1 file changed

Lines changed: 45 additions & 15 deletions

File tree

server/src/services/fileSystem.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,27 @@ export class FileSystemService {
292292
throw new Error("Cannot move root folder");
293293
}
294294

295+
const resolvedDataDir = path.resolve(this.dataDir);
296+
297+
// Validate BOTH user-supplied paths up front — validatePath throws immediately on
298+
// any traversal attempt, producing safe absolute paths.
295299
const sourceFullPath = this.validatePath(sourcePath);
300+
const destParentFullPath = destinationParentPath
301+
? this.validatePath(destinationParentPath)
302+
: resolvedDataDir;
303+
304+
// Explicit startsWith guards — recognised by static analysis as path-traversal
305+
// barriers and redundantly confirm both paths are inside the data directory.
306+
if (!sourceFullPath.startsWith(resolvedDataDir + path.sep)) {
307+
throw new Error("Invalid path: source is outside data directory");
308+
}
309+
if (destParentFullPath !== resolvedDataDir &&
310+
!destParentFullPath.startsWith(resolvedDataDir + path.sep)) {
311+
throw new Error("Invalid path: destination is outside data directory");
312+
}
296313

297-
// Verify source exists and is a directory
314+
// Verify source exists and is a directory.
315+
// sourceFullPath is guarded by the startsWith check above.
298316
let sourceStat;
299317
try {
300318
sourceStat = await fs.stat(sourceFullPath);
@@ -308,19 +326,32 @@ export class FileSystemService {
308326
throw new Error(`${sourcePath} is not a folder`);
309327
}
310328

311-
const folderName = path.basename(sourcePath);
312-
const newRelativePath = destinationParentPath
313-
? `${destinationParentPath}/${folderName}`
314-
: folderName;
329+
// Derive the folder name from the VALIDATED absolute path (not from the raw
330+
// user string). path.basename is a CodeQL-recognised sanitiser that strips
331+
// any remaining directory components.
332+
const folderName = path.basename(sourceFullPath);
315333

316-
// Prevent moving a folder into itself or a descendant
317-
if (newRelativePath === sourcePath || newRelativePath.startsWith(`${sourcePath}/`)) {
318-
throw new Error("Cannot move a folder into itself or one of its subfolders");
334+
// Build the destination absolute path entirely from validated components.
335+
// destParentFullPath was validated above; folderName came from path.basename.
336+
const newFullPath = path.join(destParentFullPath, folderName);
337+
338+
// Guard the computed destination path before any filesystem use.
339+
if (!newFullPath.startsWith(resolvedDataDir + path.sep) &&
340+
newFullPath !== resolvedDataDir) {
341+
throw new Error("Invalid path: computed destination is outside data directory");
319342
}
320343

321-
const newFullPath = this.validatePath(newRelativePath);
344+
// Relative path (for cache keys and return value) — derived from absolute paths.
345+
const newRelativePath = path.relative(resolvedDataDir, newFullPath);
322346

323-
// Check destination doesn't already exist
347+
// Prevent moving a folder into itself or a descendant (compare absolute paths).
348+
if (newFullPath === sourceFullPath ||
349+
newFullPath.startsWith(sourceFullPath + path.sep)) {
350+
throw new Error("Cannot move a folder into itself or one of its subfolders");
351+
}
352+
353+
// Check destination doesn't already exist.
354+
// newFullPath is guarded by the startsWith check above.
324355
try {
325356
await fs.access(newFullPath);
326357
throw new Error(`A folder already exists at: ${newRelativePath}`);
@@ -331,12 +362,11 @@ export class FileSystemService {
331362
// ENOENT means destination doesn't exist — that's what we want
332363
}
333364

334-
// Ensure the destination parent directory exists
335-
if (destinationParentPath) {
336-
const destParentFullPath = this.validatePath(destinationParentPath);
337-
await fs.mkdir(destParentFullPath, { recursive: true });
338-
}
365+
// Ensure the destination parent directory exists.
366+
// destParentFullPath is guarded by the startsWith check above.
367+
await fs.mkdir(destParentFullPath, { recursive: true });
339368

369+
// Both paths are guarded by startsWith checks above.
340370
await fs.rename(sourceFullPath, newFullPath);
341371

342372
// Invalidate all related caches

0 commit comments

Comments
 (0)