UNPKG

@every-env/sparkle-mcp-server

Version:

MCP server for secure Sparkle folder file access with Claude AI, including clipboard history support

205 lines 7.24 kB
import * as path from "path"; import * as fs from "fs/promises"; import * as os from "os"; export class PathValidator { config; defaultAllowedPaths; defaultBlockedPaths; constructor(config = {}) { this.config = config; // Default allowed paths this.defaultAllowedPaths = [ os.homedir(), path.join(os.homedir(), "Documents"), path.join(os.homedir(), "Downloads"), path.join(os.homedir(), "Desktop"), path.join(os.homedir(), "Sparkle"), ]; // Default blocked paths - system directories this.defaultBlockedPaths = [ "/etc", "/sys", "/proc", "/dev", "/private/etc", "/private/var", "/System", "/Library/Security", path.join(os.homedir(), ".ssh"), path.join(os.homedir(), ".gnupg"), path.join(os.homedir(), ".aws"), path.join(os.homedir(), ".config/gcloud"), ]; } async validatePath(requestedPath) { try { // Resolve to absolute path const absolutePath = path.resolve(requestedPath); // Check if path exists const stats = await fs.stat(absolutePath); // Check symlinks if not allowed if (!this.config.allowSymlinks && stats.isSymbolicLink()) { throw new Error("Symbolic links are not allowed"); } // Check if path is blocked if (this.isPathBlocked(absolutePath)) { throw new Error(`Access denied: Path is in blocked directory`); } // Check if path is in allowed directories if (!this.isPathAllowed(absolutePath)) { throw new Error(`Access denied: Path is outside allowed directories`); } // Check file size if it's a file if (stats.isFile() && this.config.maxFileSize) { if (stats.size > this.config.maxFileSize) { throw new Error(`File too large: ${stats.size} bytes exceeds limit`); } } return absolutePath; } catch (error) { if (error.code === 'ENOENT') { throw new Error(`Path does not exist: ${requestedPath}`); } throw error; } } isPathBlocked(checkPath) { const blockedPaths = [ ...this.defaultBlockedPaths, ...(this.config.blockedPaths || []) ]; return blockedPaths.some(blocked => { const blockedAbsolute = path.resolve(blocked); return checkPath.startsWith(blockedAbsolute); }); } isPathAllowed(checkPath) { const allowedPaths = this.config.allowedPaths || this.defaultAllowedPaths; return allowedPaths.some(allowed => { const allowedAbsolute = path.resolve(allowed); return checkPath.startsWith(allowedAbsolute); }); } sanitizeFilename(filename) { // Remove any path traversal attempts const sanitized = filename .replace(/\.\./g, '') .replace(/[\/\\]/g, '_') .replace(/^\./, '_'); // No hidden files // Limit length if (sanitized.length > 255) { const ext = path.extname(sanitized); const name = path.basename(sanitized, ext); return name.substring(0, 255 - ext.length) + ext; } return sanitized; } async validateSearchPath(searchPath) { const validated = await this.validatePath(searchPath); // Additional check: ensure it's a directory const stats = await fs.stat(validated); if (!stats.isDirectory()) { throw new Error("Search path must be a directory"); } return validated; } getAllowedPaths() { return this.config.allowedPaths || this.defaultAllowedPaths; } getBlockedPaths() { return [ ...this.defaultBlockedPaths, ...(this.config.blockedPaths || []) ]; } } // Rate limiting for search operations export class RateLimiter { requests = new Map(); maxRequests; windowMs; constructor(maxRequests = 100, windowMs = 60000) { this.maxRequests = maxRequests; this.windowMs = windowMs; } checkLimit(identifier) { const now = Date.now(); const requests = this.requests.get(identifier) || []; // Remove old requests outside the window const validRequests = requests.filter(time => now - time < this.windowMs); if (validRequests.length >= this.maxRequests) { return false; } // Add current request validRequests.push(now); this.requests.set(identifier, validRequests); return true; } reset(identifier) { this.requests.delete(identifier); } cleanup() { // Clean up old entries periodically const now = Date.now(); for (const [id, requests] of this.requests.entries()) { const validRequests = requests.filter(time => now - time < this.windowMs); if (validRequests.length === 0) { this.requests.delete(id); } else { this.requests.set(id, validRequests); } } } getRemainingRequests(identifier = 'default') { const now = Date.now(); const requests = this.requests.get(identifier) || []; const validRequests = requests.filter(time => now - time < this.windowMs); return Math.max(0, this.maxRequests - validRequests.length); } } // File type validator export class FileTypeValidator { allowedExtensions; blockedExtensions; constructor() { // Common safe file types this.allowedExtensions = new Set([ '.txt', '.md', '.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png', '.gif', '.svg', '.mp3', '.wav', '.m4a', '.mp4', '.mov', '.csv', '.json', '.xml', '.yaml', '.yml', '.js', '.ts', '.py', '.java', '.c', '.cpp', '.html', '.css', '.scss', '.sass', '.log', '.conf', '.cfg', '.ini', '.zip', '.tar', '.gz', '.7z', ]); // Potentially dangerous file types this.blockedExtensions = new Set([ '.exe', '.dll', '.so', '.dylib', '.app', '.dmg', '.pkg', '.deb', '.rpm', '.sh', '.bat', '.cmd', '.ps1', '.scr', '.vbs', '.js', '.jar', ]); } isAllowed(filename) { const ext = path.extname(filename).toLowerCase(); // Check blocked first if (this.blockedExtensions.has(ext)) { return false; } // If we have a whitelist, check it if (this.allowedExtensions.size > 0) { return this.allowedExtensions.has(ext); } return true; } addAllowedType(extension) { this.allowedExtensions.add(extension.toLowerCase()); } addBlockedType(extension) { this.blockedExtensions.add(extension.toLowerCase()); } } //# sourceMappingURL=security.js.map