Skip to main content
Glama
fsGuard.ts9.81 kB
/** * @fileOverview: Filesystem security guard providing secure path handling and validation * @module: FSGuard * @keyFunctions: * - guardPath(): Safely resolve and validate file paths * - readFile(): Secure file reading with path validation * - listDirectory(): Safe directory listing with filtering * - validatePath(): Check if path is within allowed bounds * @dependencies: * - fs: File system operations and realpath resolution * - path: Path manipulation and normalization * - logger: Logging utilities for security events * @context: Security layer that prevents path traversal attacks and ensures all filesystem operations are within allowed directory bounds */ import * as fs from 'fs'; import * as path from 'path'; import { logger } from '../utils/logger'; export interface FSGuardConfig { baseDir: string; allowAbsolutePaths?: boolean; maxPathLength?: number; } export interface GuardedPath { original: string; normalized: string; canonical: string; relative: string; isWithinBase: boolean; } /** * Filesystem guard that provides secure path handling and validation. * All filesystem operations should go through this guard. */ export class FSGuard { private baseDir: string; private canonicalBaseDir: string; private allowAbsolutePaths: boolean; private maxPathLength: number; constructor(config: FSGuardConfig) { this.baseDir = path.resolve(config.baseDir); // Normalize baseDir to realpath where possible; fall back to resolved path when mocked or unavailable try { const realBase = fs.realpathSync(this.baseDir); this.canonicalBaseDir = realBase || this.baseDir; } catch { this.canonicalBaseDir = this.baseDir; } this.allowAbsolutePaths = config.allowAbsolutePaths || false; this.maxPathLength = config.maxPathLength || 1000; logger.info(`FSGuard initialized with baseDir: ${this.canonicalBaseDir}`); } /** * Safely resolve and validate a path. */ async guardPath(inputPath: string): Promise<GuardedPath> { if (!inputPath || typeof inputPath !== 'string') { throw new FSGuardError('PATH_INVALID', `Invalid path input: ${inputPath}`, { input: inputPath, suggestion: 'Provide a valid string path', }); } if (inputPath.length > this.maxPathLength) { throw new FSGuardError( 'PATH_TOO_LONG', `Path exceeds maximum length (${this.maxPathLength})`, { input: inputPath, length: inputPath.length, suggestion: 'Use shorter path names', } ); } // Normalize path (convert separators, resolve '..' and '.') const normalized = path.normalize(inputPath); // Check for null bytes (security) if (normalized.includes('\0')) { throw new FSGuardError('PATH_NULL_BYTE', 'Path contains null bytes', { input: inputPath, suggestion: 'Remove null bytes from path', }); } // Resolve to absolute path let absolutePath: string; if (path.isAbsolute(normalized)) { if (!this.allowAbsolutePaths) { throw new FSGuardError('ABSOLUTE_PATH_FORBIDDEN', 'Absolute paths are not allowed', { input: inputPath, suggestion: 'Use relative paths only', }); } absolutePath = normalized; } else { absolutePath = path.resolve(this.baseDir, normalized); } // Get canonical path (resolve symlinks) let canonical: string; try { const rp = await fs.promises.realpath(absolutePath); canonical = typeof rp === 'string' && rp.length > 0 ? rp : absolutePath; } catch (error) { // If file doesn't exist, we still validate the directory structure const parentDir = path.dirname(absolutePath); try { const canonicalParentRaw = await fs.promises.realpath(parentDir); const canonicalParent = typeof canonicalParentRaw === 'string' && canonicalParentRaw.length > 0 ? canonicalParentRaw : parentDir; canonical = path.join(canonicalParent, path.basename(absolutePath)); } catch { throw new FSGuardError('PATH_INVALID', `Cannot resolve path: ${inputPath}`, { input: inputPath, resolved: absolutePath, suggestion: 'Ensure parent directory exists', }); } } // Check if path is within base directory (cross-OS safe) // Use path.relative to avoid false positives with shared prefixes const relFromBase = path.relative(this.canonicalBaseDir, canonical); const isWithinBase = relFromBase === '' || (!relFromBase.startsWith('..') && !path.isAbsolute(relFromBase)); if (!isWithinBase) { throw new FSGuardError('PATH_OUTSIDE_BASE', 'Path is outside the allowed base directory', { input: inputPath, canonical: canonical, baseDir: this.canonicalBaseDir, suggestion: 'Use paths within the project directory only', }); } const relative = path.normalize(path.relative(this.canonicalBaseDir, canonical)); return { original: inputPath, normalized, canonical, relative, isWithinBase, }; } /** * Safe file read with path validation. */ async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise<string> { const guardedPath = await this.guardPath(filePath); try { return await fs.promises.readFile(guardedPath.canonical, encoding); } catch (error) { throw new FSGuardError('FILE_READ_ERROR', `Cannot read file: ${filePath}`, { input: filePath, canonical: guardedPath.canonical, originalError: error instanceof Error ? error.message : String(error), suggestion: 'Ensure file exists and is readable', }); } } /** * Safe file stat with path validation. */ async stat(filePath: string): Promise<fs.Stats> { const guardedPath = await this.guardPath(filePath); try { return await fs.promises.stat(guardedPath.canonical); } catch (error) { throw new FSGuardError('FILE_STAT_ERROR', `Cannot stat file: ${filePath}`, { input: filePath, canonical: guardedPath.canonical, originalError: error instanceof Error ? error.message : String(error), suggestion: 'Ensure file exists', }); } } /** * Safe directory listing with path validation. */ async readdir(dirPath: string): Promise<fs.Dirent[]> { const guardedPath = await this.guardPath(dirPath); try { return await fs.promises.readdir(guardedPath.canonical, { withFileTypes: true }); } catch (error) { throw new FSGuardError('DIR_READ_ERROR', `Cannot read directory: ${dirPath}`, { input: dirPath, canonical: guardedPath.canonical, originalError: error instanceof Error ? error.message : String(error), suggestion: 'Ensure directory exists and is readable', }); } } /** * Check if path exists safely. */ async exists(filePath: string): Promise<boolean> { try { const guardedPath = await this.guardPath(filePath); await fs.promises.access(guardedPath.canonical); return true; } catch { return false; } } /** * Get the base directory. */ getBaseDir(): string { return this.canonicalBaseDir; } /** * Validate multiple paths at once. */ async guardPaths(paths: string[]): Promise<GuardedPath[]> { const results: GuardedPath[] = []; const errors: FSGuardError[] = []; for (let i = 0; i < paths.length; i++) { try { const guardedPath = await this.guardPath(paths[i]); results.push(guardedPath); } catch (error) { if (error instanceof FSGuardError) { errors.push(error); } else { errors.push( new FSGuardError('PATH_VALIDATION_ERROR', `Error validating path ${i}: ${paths[i]}`, { input: paths[i], index: i, originalError: error instanceof Error ? error.message : String(error), }) ); } } } if (errors.length > 0) { throw new FSGuardError('MULTIPLE_PATH_ERRORS', `Failed to validate ${errors.length} paths`, { errors: errors.map(e => ({ code: e.code, message: e.message, context: e.context })), suggestion: 'Fix path validation errors before proceeding', }); } return results; } } /** * Structured error class for filesystem guard operations. */ export class FSGuardError extends Error { public readonly code: string; public readonly context: Record<string, any>; constructor(code: string, message: string, context: Record<string, any> = {}) { super(message); this.name = 'FSGuardError'; this.code = code; this.context = context; } /** * Get a structured error response for MCP tools. */ toStructured(): { error: { code: string; message: string; context: Record<string, any>; suggestion?: string; examples?: Record<string, any>; }; } { return { error: { code: this.code, message: this.message, context: this.context, suggestion: this.context.suggestion, examples: this.getExamples(), }, }; } private getExamples(): Record<string, any> { switch (this.code) { case 'PATH_OUTSIDE_BASE': return { good_call: { filePath: 'src/components/Button.tsx' }, bad_call: { filePath: '../../../etc/passwd' }, }; case 'ABSOLUTE_PATH_FORBIDDEN': return { good_call: { filePath: 'src/utils/helpers.ts' }, bad_call: { filePath: '/home/user/project/src/helpers.ts' }, }; default: return {}; } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sbarron/AmbianceMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server