refactor(backend): defensive path validation in FileSystemService (#802)

Adds two private helpers and routes the legacy stack-scoped methods
through them so every fs call has a name + path check immediately
above the call site:

- assertWithinBase(filePath): throws INVALID_PATH if the path resolves
  outside this.baseDir. Wired into the bare readFile/writeFile/access
  wrappers and hasComposeFile(dir).
- resolveStackDir(stackName): throws INVALID_STACK_NAME if the name
  fails isValidStackName, then asserts the joined path is within base.
  Wired into getComposeFilePath, saveStackContent, envExists,
  getEnvContent, saveEnvContent, createStack, deleteStack,
  backupStackFiles, restoreStackFiles.

Routes still pre-validate at the request boundary; this is the
second line of defense and gives static analyzers a guard they can
trace through. The existing resolveSafeStackPath used by file-explorer
methods is unchanged (it adds symlink-escape detection on top).

The duplicate inline regex in createStack is removed because
resolveStackDir now performs the same check via isValidStackName.
This commit is contained in:
Anso
2026-04-27 09:37:41 -04:00
committed by GitHub
parent c18d3696e9
commit 77f27b4bf9
+33 -14
View File
@@ -2,7 +2,7 @@ import path from 'path';
import { promises as fsPromises, createReadStream } from 'fs';
import type { Readable } from 'stream';
import { NodeRegistry } from './NodeRegistry';
import { isPathWithinBase } from '../utils/validation';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { isBinaryBuffer } from '../utils/binaryDetect';
export interface FileEntry {
@@ -61,7 +61,23 @@ export class FileSystemService {
return new FileSystemService(nodeId);
}
private assertWithinBase(filePath: string): void {
if (!isPathWithinBase(filePath, this.baseDir)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
}
private resolveStackDir(stackName: string): string {
if (!isValidStackName(stackName)) {
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
}
const stackDir = path.join(this.baseDir, stackName);
this.assertWithinBase(stackDir);
return stackDir;
}
async hasComposeFile(dir: string): Promise<boolean> {
this.assertWithinBase(dir);
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
try {
@@ -75,7 +91,7 @@ export class FileSystemService {
}
private async getComposeFilePath(stackName: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName);
const stackDir = this.resolveStackDir(stackName);
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
const filePath = path.join(stackDir, file);
@@ -123,7 +139,8 @@ export class FileSystemService {
}
async saveStackContent(stackName: string, content: string): Promise<void> {
const filePath = path.join(this.baseDir, stackName, 'compose.yaml');
const stackDir = this.resolveStackDir(stackName);
const filePath = path.join(stackDir, 'compose.yaml');
try {
await fsPromises.writeFile(filePath, content, 'utf-8');
} catch (error) {
@@ -133,8 +150,9 @@ export class FileSystemService {
}
async envExists(stackName: string): Promise<boolean> {
const stackDir = this.resolveStackDir(stackName);
try {
await fsPromises.access(path.join(this.baseDir, stackName, '.env'));
await fsPromises.access(path.join(stackDir, '.env'));
return true;
} catch {
return false;
@@ -142,19 +160,23 @@ export class FileSystemService {
}
async readFile(filePath: string, encoding: BufferEncoding = 'utf-8'): Promise<string> {
this.assertWithinBase(filePath);
return fsPromises.readFile(filePath, encoding);
}
async writeFile(filePath: string, content: string, encoding: BufferEncoding = 'utf-8'): Promise<void> {
this.assertWithinBase(filePath);
return fsPromises.writeFile(filePath, content, encoding);
}
async access(filePath: string): Promise<void> {
this.assertWithinBase(filePath);
return fsPromises.access(filePath);
}
async getEnvContent(stackName: string): Promise<string> {
const envPath = path.join(this.baseDir, stackName, '.env');
const stackDir = this.resolveStackDir(stackName);
const envPath = path.join(stackDir, '.env');
try {
return await fsPromises.readFile(envPath, 'utf-8');
} catch (error) {
@@ -164,7 +186,8 @@ export class FileSystemService {
}
async saveEnvContent(stackName: string, content: string): Promise<void> {
const envPath = path.join(this.baseDir, stackName, '.env');
const stackDir = this.resolveStackDir(stackName);
const envPath = path.join(stackDir, '.env');
try {
await fsPromises.writeFile(envPath, content, 'utf-8');
} catch (error) {
@@ -174,11 +197,7 @@ export class FileSystemService {
}
async createStack(stackName: string): Promise<void> {
if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) {
throw new Error('Stack name must contain only alphanumeric characters, underscores, or hyphens');
}
const stackDir = path.join(this.baseDir, stackName);
const stackDir = this.resolveStackDir(stackName);
try {
await fsPromises.access(stackDir);
@@ -205,7 +224,7 @@ export class FileSystemService {
}
public async deleteStack(stackName: string): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
const stackDir = this.resolveStackDir(stackName);
try {
await fsPromises.rm(stackDir, { recursive: true, force: true });
} catch (error: unknown) {
@@ -281,7 +300,7 @@ export class FileSystemService {
async backupStackFiles(stackName: string): Promise<void> {
const debug = isDebugEnabled();
const t0 = Date.now();
const stackDir = path.join(this.baseDir, stackName);
const stackDir = this.resolveStackDir(stackName);
const backupDir = path.join(getBackupBaseDir(), stackName);
await fsPromises.mkdir(backupDir, { recursive: true });
@@ -320,7 +339,7 @@ export class FileSystemService {
async restoreStackFiles(stackName: string): Promise<void> {
const debug = isDebugEnabled();
const t0 = Date.now();
const stackDir = path.join(this.baseDir, stackName);
const stackDir = this.resolveStackDir(stackName);
const backupDir = path.join(getBackupBaseDir(), stackName);
const items = await fsPromises.readdir(backupDir);