Skip to main content
Glama
pathValidator.ts5.91 kB
/** * Path validation for security - prevents path traversal attacks * * SYMLINK BEHAVIOR: * ================== * This validator has TWO different behaviors for symlinks: * * 1. PATH VALIDATION (Security - Always Resolves): * - Symlinks are ALWAYS resolved to their real paths using fs.realpathSync() * - The real path is then validated against allowed roots * - This prevents symlink-based path traversal attacks * - Example: /workspace/link -> /etc/passwd would be blocked * - Cannot be disabled (security requirement) * * 2. TOOL TRAVERSAL (Performance - Configurable): * - By default, tools DON'T follow symlinks during directory traversal * - Use followSymlinks=true in tool options to enable * - This matches ripgrep and find default behavior * - Performance: Following symlinks can significantly slow down searches * - Safety: May cause infinite loops with circular symlinks * * RATIONALE: * - Security validation must resolve symlinks to prevent attacks * - Tool traversal defaults to NOT following for performance * - Users can opt-in to symlink following per operation * - Symlink targets are still validated (must be within workspace) * * CONSTANTS: * - SECURITY_DEFAULTS.VALIDATE_SYMLINK_TARGETS = true (always) * - SECURITY_DEFAULTS.DEFAULT_FOLLOW_SYMLINKS = false (tool default) */ import path from 'path'; import fs from 'fs'; import type { ValidationResult } from '../types.js'; import { shouldIgnore } from './ignoredPathFilter.js'; /** * PathValidator class for validating and sanitizing file system paths */ export class PathValidator { private allowedRoots: string[]; /** * Creates a new PathValidator * @param workspaceRoot - Optional workspace root directory. Defaults to current working directory. */ constructor(workspaceRoot?: string) { this.allowedRoots = workspaceRoot ? [path.resolve(workspaceRoot)] : [process.cwd()]; } /** * Adds an allowed root directory */ addAllowedRoot(root: string): void { const resolvedRoot = path.resolve(root); if (!this.allowedRoots.includes(resolvedRoot)) { this.allowedRoots.push(resolvedRoot); } } /** * Validates a path to ensure it's within allowed directories * * SECURITY NOTE: This method ALWAYS resolves symlinks to their real paths * before validation. This prevents symlink-based path traversal attacks. * This behavior cannot be disabled as it's a core security requirement. * * @param inputPath - The path to validate */ validate(inputPath: string): ValidationResult { if (!inputPath || inputPath.trim() === '') { return { isValid: false, error: 'Path cannot be empty', }; } // Resolve to absolute path (normalizes ./ and ../ but doesn't resolve symlinks yet) const absolutePath = path.resolve(inputPath); // Check if path is within allowed roots // Must be the root itself OR start with root + path separator const isAllowed = this.allowedRoots.some((root) => { // Exact match if (absolutePath === root) { return true; } // Must start with root + separator to ensure it's truly a child path return absolutePath.startsWith(root + path.sep); }); if (!isAllowed) { return { isValid: false, error: `Path '${inputPath}' is outside allowed directories. Allowed roots: ${this.allowedRoots.join(', ')}`, }; } // Check if path should be ignored (.git, .env, etc.) if (shouldIgnore(absolutePath)) { return { isValid: false, error: `Path '${inputPath}' is in an ignored directory or matches an ignored pattern`, }; } // Check for symlink traversal by resolving real path in a single step try { const realPath = fs.realpathSync(absolutePath); const isRealPathAllowed = this.allowedRoots.some((root) => { // Exact match or starts with root + separator return realPath === root || realPath.startsWith(root + path.sep); }); if (!isRealPathAllowed) { return { isValid: false, error: `Symlink target '${realPath}' is outside allowed directories`, }; } // Check if resolved symlink target should be ignored if (shouldIgnore(realPath)) { return { isValid: false, error: `Symlink target '${realPath}' is in an ignored directory or matches an ignored pattern`, }; } return { isValid: true, sanitizedPath: realPath, }; } catch { // Path doesn't exist yet or can't be resolved - that's okay for some operations // We'll still allow it if it's within the allowed roots } return { isValid: true, sanitizedPath: absolutePath, }; } /** * Checks if a path exists and is accessible */ async exists(inputPath: string): Promise<boolean> { const validation = this.validate(inputPath); if (!validation.isValid || !validation.sanitizedPath) { return false; } try { await fs.promises.access(validation.sanitizedPath, fs.constants.R_OK); return true; } catch { return false; } } /** * Gets the type of a path (file, directory, symlink) */ async getType( inputPath: string ): Promise<'file' | 'directory' | 'symlink' | null> { const validation = this.validate(inputPath); if (!validation.isValid || !validation.sanitizedPath) { return null; } try { const stats = await fs.promises.lstat(validation.sanitizedPath); if (stats.isFile()) return 'file'; if (stats.isDirectory()) return 'directory'; if (stats.isSymbolicLink()) return 'symlink'; return null; } catch { return null; } } } /** * Global path validator instance */ export const pathValidator = new PathValidator();

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/bgauryy/local-explorer-mcp'

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