Skip to main content
Glama

MCP Gemini Server

by bsmi021
FileSecurityService.ts16.9 kB
import * as fs from "fs/promises"; import * as fsSync from "fs"; import * as path from "path"; import { logger } from "./logger.js"; import { ValidationError } from "./errors.js"; /** * Type guard to check if an error is an ENOENT (file not found) error * @param err - The error to check * @returns True if the error is an ENOENT error */ function isENOENTError(err: unknown): boolean { return ( err !== null && typeof err === "object" && "code" in err && err.code === "ENOENT" ); } /** * Type guard to check if an error has a message property * @param err - The error to check * @returns True if the error has a message property */ function hasErrorMessage(err: unknown): err is { message: string } { return ( err !== null && typeof err === "object" && "message" in err && typeof err.message === "string" ); } /** * Centralized service for handling file-related security operations * Provides comprehensive validation, resolution, and secure file operations */ export class FileSecurityService { private allowedDirectories: string[] = []; private secureBasePath?: string; // Default safe base directory - using the project root as the default private readonly DEFAULT_SAFE_BASE_DIR: string = process.env.GEMINI_SAFE_FILE_BASE_DIR || path.resolve(process.cwd()); /** * Creates a new instance of the FileSecurityService * @param allowedDirectories Optional array of allowed directories for file operations * @param secureBasePath Optional single secure base path (takes precedence over env vars) */ constructor(allowedDirectories?: string[], secureBasePath?: string) { // Initialize with environment variable if set this.secureBasePath = process.env.GEMINI_SAFE_FILE_BASE_DIR || (secureBasePath ? path.normalize(secureBasePath) : undefined); // Initialize allowed directories if (allowedDirectories && allowedDirectories.length > 0) { this.setAllowedDirectories(allowedDirectories); } else if (this.secureBasePath) { this.allowedDirectories = [this.secureBasePath]; } else { this.allowedDirectories = [path.resolve(process.cwd())]; } logger.info( `File operations restricted to: ${this.allowedDirectories.join(", ")}` ); } /** * Sets the secure base directory for file operations. * @param basePath The absolute path to restrict file operations to */ public setSecureBasePath(basePath: string): void { if (!path.isAbsolute(basePath)) { throw new ValidationError("Base path must be absolute"); } // Store the base path in a private field this.secureBasePath = path.normalize(basePath); // Update allowed directories to include this path if (!this.allowedDirectories.includes(this.secureBasePath)) { this.allowedDirectories.push(this.secureBasePath); } logger.debug(`Secure base path set to: ${this.secureBasePath}`); } /** * Gets the current secure base directory if set */ public getSecureBasePath(): string | undefined { return this.secureBasePath; } /** * Sets the allowed directories for file operations * @param directories Array of absolute paths allowed for file operations */ public setAllowedDirectories(directories: string[]): void { if (!directories || directories.length === 0) { throw new ValidationError( "At least one allowed directory must be provided" ); } // Validate all directories are absolute paths for (const dir of directories) { if (!path.isAbsolute(dir)) { throw new ValidationError(`Directory path must be absolute: ${dir}`); } } // Store normalized paths this.allowedDirectories = directories.map((dir) => path.normalize(dir)); logger.debug( `Allowed directories set to: ${this.allowedDirectories.join(", ")}` ); } /** * Gets the current allowed directories */ public getAllowedDirectories(): string[] { return [...this.allowedDirectories]; } /** * Validates that a file path is secure and resolves it to an absolute path * Can work with either a base directory or multiple allowed directories * * @param filePath The file path to validate * @param options Optional configuration * @returns The validated absolute file path * @throws ValidationError if the path is invalid or insecure */ public validateAndResolvePath( filePath: string, options: { mustExist?: boolean; allowedDirs?: string[]; basePath?: string; } = {} ): string { const { mustExist = false, allowedDirs, basePath } = options; // Determine which allowed directories to use const effectiveAllowedDirs = allowedDirs || (basePath ? [path.normalize(basePath)] : this.allowedDirectories); logger.debug(`Validating file path: ${filePath}`); logger.debug( `Using allowed directories: ${effectiveAllowedDirs.join(", ")}` ); // Resolve the absolute path const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(this.secureBasePath || process.cwd(), filePath); // Normalize path to handle . and .. segments const normalizedPath = path.normalize(absolutePath); // Check if the path is within any allowed directory if (!this.isPathWithinAllowedDirs(normalizedPath, effectiveAllowedDirs)) { logger.warn( `Access denied: Path not in allowed directories: ${filePath}` ); throw new ValidationError( `Access denied: The file path must be within the allowed directories` ); } // Check if the file exists (if required) if (mustExist) { try { fsSync.accessSync(normalizedPath, fsSync.constants.F_OK); } catch (error) { logger.warn(`File not found: ${normalizedPath}`); throw new ValidationError(`File not found: ${normalizedPath}`); } } logger.debug(`Validated path: ${normalizedPath}`); return normalizedPath; } /** * Checks if a given file path is within any of the allowed directories. * * @param filePath The relative or absolute path to check. * @param allowedDirs Optional array of allowed directory paths (defaults to instance's allowed directories) * @returns True if the file path is within any of the allowed directories, false otherwise. */ public isPathWithinAllowedDirs( filePath: string, allowedDirs?: string[] ): boolean { // Use instance's allowed directories if none provided const effectiveAllowedDirs = allowedDirs || this.allowedDirectories; // Return false if effectiveAllowedDirs is empty if (!effectiveAllowedDirs || effectiveAllowedDirs.length === 0) { return false; } // Canonicalize the file path to an absolute path const resolvedFilePath = path.resolve(filePath); // Normalize the path to handle sequences like '..' const normalizedFilePath = path.normalize(resolvedFilePath); // Check if the file path is within any of the allowed directories for (const allowedDir of effectiveAllowedDirs) { // Normalize the allowed directory path const normalizedAllowedDir = path.normalize(path.resolve(allowedDir)); // Check if it's an allowed directory containing the file, or an exact match if ( normalizedFilePath.startsWith(normalizedAllowedDir + path.sep) || normalizedFilePath === normalizedAllowedDir ) { // Additional check: ensure no upward traversal after matching the prefix const relativePath = path.relative( normalizedAllowedDir, normalizedFilePath ); if (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) { return true; } } } return false; } /** * Fully resolves a file path, handling symlinks and security checks * * @param filePath The file path to resolve * @returns The fully resolved file path * @throws ValidationError if the path contains insecure symlinks */ public async fullyResolvePath(filePath: string): Promise<string> { const normalizedPath = path.normalize(path.resolve(filePath)); try { // Check if the target file exists and is a symlink try { const stats = await fs.lstat(normalizedPath); if (stats.isSymbolicLink()) { logger.warn(`Path is a symlink: ${normalizedPath}`); const target = await fs.readlink(normalizedPath); const resolvedPath = path.resolve( path.dirname(normalizedPath), target ); // Ensure the symlink target is within allowed directories if (!this.isPathWithinAllowedDirs(resolvedPath)) { throw new ValidationError( `Security error: Symlink target is outside allowed directories: ${resolvedPath}` ); } return resolvedPath; } } catch (err) { // If file doesn't exist (ENOENT), that's fine in many cases if (!isENOENTError(err)) { throw err; } } // Also check parent directories to ensure we're not inside a symlinked directory let currentPath = path.dirname(normalizedPath); const root = path.parse(currentPath).root; // Track resolved parent paths const resolvedPaths = new Map<string, string>(); while (currentPath !== root) { try { const dirStats = await fs.lstat(currentPath); if (dirStats.isSymbolicLink()) { // Resolve the symlink const linkTarget = await fs.readlink(currentPath); const resolvedPath = path.resolve( path.dirname(currentPath), linkTarget ); logger.warn( `Parent directory is a symlink: ${currentPath} -> ${resolvedPath}` ); resolvedPaths.set(currentPath, resolvedPath); // If this is the immediate parent, update the final path if (currentPath === path.dirname(normalizedPath)) { const updatedPath = path.join( resolvedPath, path.basename(normalizedPath) ); // Ensure resolved path is still secure if (!this.isPathWithinAllowedDirs(updatedPath)) { throw new ValidationError( `Security error: Resolved symlink path is outside allowed directories: ${updatedPath}` ); } return updatedPath; } } } catch (err) { if (!isENOENTError(err)) { throw err; } } currentPath = path.dirname(currentPath); } // If we found symlinks in parent directories, perform a final security check if (resolvedPaths.size > 0) { try { // Get fully resolved path including all symlinks const finalResolvedPath = await fs.realpath(normalizedPath); // Final security check with the fully resolved path if (!this.isPathWithinAllowedDirs(finalResolvedPath)) { throw new ValidationError( `Security error: Resolved path is outside allowed directories: ${finalResolvedPath}` ); } return finalResolvedPath; } catch (err) { // Handle case where path doesn't exist yet if (isENOENTError(err)) { // Try to resolve just the directory part const resolvedDir = await fs .realpath(path.dirname(normalizedPath)) .catch((dirErr) => { if (isENOENTError(dirErr)) { return path.dirname(normalizedPath); } throw dirErr; }); const finalPath = path.join( resolvedDir, path.basename(normalizedPath) ); // Final security check if (!this.isPathWithinAllowedDirs(finalPath)) { throw new ValidationError( `Security error: Resolved path is outside allowed directories: ${finalPath}` ); } return finalPath; } throw err; } } // No symlinks found, return the normalized path return normalizedPath; } catch (err) { if (hasErrorMessage(err) && err.message.includes("Security error:")) { // Re-throw security errors throw err; } // For other errors, provide a clearer message const errorMsg = hasErrorMessage(err) ? err.message : String(err); logger.error(`Error resolving path: ${errorMsg}`, err); throw new ValidationError(`Error validating path security: ${errorMsg}`); } } /** * Securely writes content to a file, ensuring the path is within allowed directories. * * @param filePath The relative or absolute path to the file. * @param content The string content to write to the file. * @param options Optional configuration * @returns A promise that resolves when the file is written * @throws ValidationError if the path is invalid, outside allowed directories, * if the file exists and overwrite is false, or for any other security/file system error */ public async secureWriteFile( filePath: string, content: string, options: { overwrite?: boolean; allowedDirs?: string[]; } = {} ): Promise<void> { const { overwrite = false, allowedDirs } = options; // Use instance's allowed directories if none provided const effectiveAllowedDirs = allowedDirs || this.allowedDirectories; // 1. Initial validation against allowed directories const validatedPath = this.validateAndResolvePath(filePath, { allowedDirs: effectiveAllowedDirs, }); // 2. Fully resolve the path handling symlinks and do final security check const finalFilePath = await this.fullyResolvePath(validatedPath); // 3. Check if file exists and overwrite flag is false if (!overwrite) { try { await fs.access(finalFilePath); // If we get here, the file exists logger.error( `File already exists and overwrite is false: ${finalFilePath}` ); throw new ValidationError( `File already exists: ${filePath}. Set overwrite flag to true to replace it.` ); } catch (err) { // File doesn't exist or other access error - this is expected for new files if (!isENOENTError(err)) { // If error is not "file doesn't exist", it's another access error logger.error(`Error checking file existence: ${finalFilePath}`, err); const errorMsg = hasErrorMessage(err) ? err.message : String(err); throw new ValidationError(`Error checking file access: ${errorMsg}`); } // If err.code === 'ENOENT', the file doesn't exist, which is fine for new files } } // 4. Create parent directories if they don't exist const dirPath = path.dirname(finalFilePath); try { await fs.mkdir(dirPath, { recursive: true }); } catch (err) { logger.error(`Error creating directory ${dirPath}:`, err); const errorMsg = hasErrorMessage(err) ? err.message : String(err); throw new ValidationError( `Failed to create directory structure: ${errorMsg}` ); } // 5. Write the file try { await fs.writeFile(finalFilePath, content, "utf8"); logger.info(`Successfully wrote file to ${finalFilePath}`); } catch (err) { logger.error(`Error writing file ${finalFilePath}:`, err); const errorMsg = hasErrorMessage(err) ? err.message : String(err); throw new ValidationError(`Failed to write file: ${errorMsg}`); } } /** * Initializes file path security from environment variables * Call this during application startup */ public static configureFromEnvironment(): FileSecurityService { const customBaseDir = process.env.GEMINI_SAFE_FILE_BASE_DIR; const service = new FileSecurityService(); if (customBaseDir) { // Validate that the custom base directory exists try { fsSync.accessSync(customBaseDir, fsSync.constants.F_OK); logger.info(`File operations restricted to: ${customBaseDir}`); service.setAllowedDirectories([customBaseDir]); } catch (error) { logger.warn( `Configured GEMINI_SAFE_FILE_BASE_DIR does not exist: ${customBaseDir}` ); logger.warn(`Falling back to default directory: ${process.cwd()}`); service.setAllowedDirectories([process.cwd()]); } } else { logger.info( `File operations restricted to current working directory: ${process.cwd()}` ); service.setAllowedDirectories([process.cwd()]); } return service; } }

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/bsmi021/mcp-gemini-server'

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