Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
pathValidator.tsโ€ข6.26 kB
import path from 'node:path'; import fs from 'node:fs/promises'; import { logger } from '../utils/logger.js'; import { RegexValidator } from './regexValidator.js'; export class PathValidator { private static ALLOWED_DIRECTORIES: string[] = []; private static ALLOWED_EXTENSIONS: string[] = ['.md', '.markdown', '.txt', '.yml', '.yaml']; static initialize(personasDir: string, allowedExtensions?: string[]): void { this.ALLOWED_DIRECTORIES = [ path.resolve(personasDir), path.resolve('./personas'), path.resolve('./custom-personas'), path.resolve('./backups'), path.resolve(process.env.PERSONAS_DIR || './personas') ]; if (allowedExtensions) { this.ALLOWED_EXTENSIONS = allowedExtensions; } } /** * SECURITY FIX #1290: Resolve symlinks to prevent path traversal * Helper function to resolve symlinks in paths */ private static async resolveSymlinks(resolvedPath: string, userPath: string): Promise<string> { try { // Try to resolve symlinks in the full path const realPath = await fs.realpath(resolvedPath); // Log symlink resolution for security auditing if (realPath !== resolvedPath) { logger.warn('Symlink detected and resolved', { requestedPath: userPath, resolvedPath, realPath }); } return realPath; } catch (err) { // If path doesn't exist (e.g., creating new file), resolve parent directory if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return this.resolveParentSymlink(resolvedPath, userPath); } throw err; } } /** * Helper to resolve symlinks in parent directory when target doesn't exist */ private static async resolveParentSymlink(resolvedPath: string, userPath: string): Promise<string> { const parentDir = path.dirname(resolvedPath); try { const realParent = await fs.realpath(parentDir); // Log parent symlink resolution for security auditing if (realParent !== parentDir) { logger.warn('Parent directory symlink detected and resolved', { requestedPath: userPath, parentDir, realParent }); } // Reconstruct path with resolved parent and original filename return path.join(realParent, path.basename(resolvedPath)); } catch { // Parent directory doesn't exist - use resolved path // (will fail later in file operations, but not a security issue) return resolvedPath; } } /** * Validate that the real path is within allowed directories */ private static validatePathIsAllowed(realPath: string, userPath: string): void { const allowedDirs = this.ALLOWED_DIRECTORIES.length === 0 ? [path.resolve('./personas'), path.resolve(process.env.PERSONAS_DIR || './personas')] : this.ALLOWED_DIRECTORIES; const isAllowed = allowedDirs.some(allowedDir => realPath.startsWith(allowedDir + path.sep) || realPath === allowedDir ); if (!isAllowed) { // SECURITY FIX #206: Don't expose user paths in error messages logger.error('Path access denied', { path: userPath }); throw new Error('Path access denied'); } } /** * Validate filename extension and format */ private static validateFilename(realPath: string): void { if (!path.extname(realPath)) { return; // Not a file, skip validation } const filename = path.basename(realPath); const ext = path.extname(filename).toLowerCase(); // Check if extension is allowed if (!this.ALLOWED_EXTENSIONS.includes(ext)) { throw new Error(`File extension not allowed: ${ext}. Allowed: ${this.ALLOWED_EXTENSIONS.join(', ')}`); } // Validate filename format (alphanumeric, dash, underscore, dot) if (!RegexValidator.validate(filename, /^[a-zA-Z0-9\-_.]+$/i, { maxLength: 255 })) { throw new Error(`Invalid filename format: ${filename}`); } } static async validatePersonaPath(userPath: string): Promise<string> { if (!userPath || typeof userPath !== 'string') { throw new Error('Path must be a non-empty string'); } // Remove any null bytes const cleanPath = userPath.replaceAll(/\u0000/g, ''); // NOSONAR - Removing null bytes for security // Normalize and resolve path const normalizedPath = path.normalize(cleanPath); const resolvedPath = path.resolve(normalizedPath); // Check for path traversal attempts if (normalizedPath.includes('..') || cleanPath.includes('..')) { logger.warn('Path traversal attempt detected', { userPath }); throw new Error('Path traversal detected'); } // Resolve symlinks to get real path const realPath = await this.resolveSymlinks(resolvedPath, userPath); // Validate path is within allowed directories this.validatePathIsAllowed(realPath, userPath); // Validate filename extension and format this.validateFilename(realPath); // Return the real path (with symlinks resolved) for safe file operations return realPath; } static async safeReadFile(filePath: string): Promise<string> { const validatedPath = await this.validatePersonaPath(filePath); // Check file exists and is not a directory const stats = await fs.stat(validatedPath); if (stats.isDirectory()) { throw new Error('Path is a directory, not a file'); } // Size check if (stats.size > 500000) { // 500KB throw new Error('File too large'); } return fs.readFile(validatedPath, 'utf-8'); } static async safeWriteFile(filePath: string, content: string): Promise<void> { const validatedPath = await this.validatePersonaPath(filePath); // Content validation if (content.length > 500000) { throw new Error('Content too large'); } // Ensure directory exists before atomic write const dirPath = path.dirname(validatedPath); await fs.mkdir(dirPath, { recursive: true }); // Write to temp file first (atomic write) const tempPath = `${validatedPath}.tmp`; await fs.writeFile(tempPath, content, 'utf-8'); // Rename to final path (atomic on most filesystems) await fs.rename(tempPath, validatedPath); } }

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/DollhouseMCP/DollhouseMCP'

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