UNPKG

@clicktime/mcp-server

Version:

ClickTime MCP Tech Demo for AI agents to interact with ClickTime API

306 lines (301 loc) 12.6 kB
// src/resources.ts import * as fs from 'fs/promises'; import * as path from 'path'; import { ReceiptProcessor } from './expense/receipt-processor.js'; // Security error class class SecurityError extends Error { constructor(message) { super(message); this.name = 'SecurityError'; } } export class ResourceHandlers { allowedDirectories = []; maxFileSize = 2 * 1024 * 1024; // 2MB - ClickTime's actual API limit directoryCache = new Map(); // Cache for path resolution /** * SECURITY: Validate and sanitize environment variable access */ static getSecureHomeDirectory() { const home = process.env.HOME; if (!home || home.includes('..') || home.includes('\0')) { // Fallback to current working directory if HOME is suspicious return process.cwd(); } return home; } constructor(allowedPaths) { // SECURITY: More restrictive default - require explicit path configuration // For local desktop usage, default to user's home directory with validation this.allowedDirectories = allowedPaths && allowedPaths.length > 0 ? allowedPaths : [path.resolve(ResourceHandlers.getSecureHomeDirectory())]; // SECURITY: Normalize and resolve all allowed directories on construction this.allowedDirectories = this.allowedDirectories.map((dir) => path.resolve(path.normalize(dir))); } /** * SECURITY: Validate file path against directory traversal attacks * Robust validation that handles symlinks and ensures path is within allowed directories */ validateFilePath(filePath) { try { // Step 1: Normalize and resolve the input path const normalizedPath = path.normalize(filePath); const resolvedPath = path.resolve(normalizedPath); // Step 2: Check if resolved path is within any allowed directory const isAllowed = this.allowedDirectories.some((allowedDir) => { // Use path.relative to safely determine if file is within directory const relativePath = path.relative(allowedDir, resolvedPath); // Path is allowed if: // 1. relative path doesn't start with '..' (not outside directory) // 2. relative path is not absolute (safety check) // 3. relative path is not empty (exact directory match is allowed) return (relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))); }); if (!isAllowed) { // SECURITY: Don't expose internal paths in error messages throw new SecurityError('File access denied. The requested file is outside allowed directories.'); } return resolvedPath; } catch (error) { if (error instanceof SecurityError) { throw error; } // Convert other errors to security-safe messages throw new SecurityError('Invalid file path'); } } /** * SECURITY: Additional validation for file safety with real path resolution */ async validateFileAccess(filePath) { try { // Resolve symlinks for final security check let realPath; try { realPath = await fs.realpath(filePath); } catch (error) { // If realpath fails (file doesn't exist), use the resolved path // This maintains compatibility with file creation workflows if (error.code === 'ENOENT') { realPath = filePath; } else { // For other errors (permission denied, etc.), wrap in generic message throw new Error('Cannot validate file access'); } } // Re-validate the real path (in case symlinks point outside allowed dirs) this.validateFilePath(realPath); // If file exists, perform additional checks try { const stats = await fs.stat(realPath); // Security: Ensure it's actually a file, not a directory or special file if (!stats.isFile()) { throw new SecurityError('Path is not a regular file'); } // Security: Check file size before reading if (stats.size > this.maxFileSize) { throw new Error(`File size ${ReceiptProcessor.formatFileSize(stats.size)} exceeds maximum allowed size of ${ReceiptProcessor.formatFileSize(this.maxFileSize)}`); } // Security: Basic check for empty files if (stats.size === 0) { throw new Error('File is empty'); } } catch (error) { // If file doesn't exist, that's okay for some workflows if (error.code !== 'ENOENT') { throw error; } } return realPath; } catch (error) { if (error instanceof SecurityError || error instanceof Error) { throw error; } throw new Error('Cannot validate file access'); } } /** * SECURITY: Additional URI validation to prevent injection attacks */ validateUri(uri) { // Prevent null bytes, control characters, and other malicious patterns if (uri.includes('\0') || /[\x00-\x1f\x7f-\x9f]/.test(uri)) { throw new SecurityError('Invalid characters in URI'); } // Prevent excessively long URIs that could cause DoS if (uri.length > 2048) { throw new SecurityError('URI too long'); } // Additional protocol validation if (!uri.startsWith('file://')) { throw new SecurityError('Only file:// URIs are supported'); } } /** * List available resources */ async listResources() { const resources = []; // Add template resource to show users how to use it resources.push({ uri: 'file://local/example.jpg', name: 'Receipt Images', description: 'Local receipt images for expense processing. Save your receipt images anywhere accessible and reference the full path.', mimeType: 'image/*', }); // Scan allowed directories for actual image files for (const dir of this.allowedDirectories) { try { await this.scanDirectoryForImages(dir, resources); } catch (error) { // Directory might not exist, continue with others continue; } } return resources; } /** * Read resource content with enhanced security validation */ async readResource(request) { const uri = request.uri; // SECURITY: Enhanced URI validation this.validateUri(uri); // Extract file path from URI and decode any URI encoding const filePath = decodeURIComponent(uri.replace('file://', '')); // SECURITY: Validate file path and resolve symlinks const validatedPath = this.validateFilePath(filePath); const realPath = await this.validateFileAccess(validatedPath); // Validate file type using extension const extension = path.extname(realPath).toLowerCase(); const mimeType = this.getMimeTypeFromExtension(extension); if (!mimeType) { throw new Error(`Unsupported file type: ${extension}. ClickTime supports: .jpg, .jpeg, .png, .gif, .bmp, .pdf`); } // Read file with size limit enforcement const fileBuffer = await fs.readFile(realPath); // Double-check file size after reading (defense in depth) if (fileBuffer.length > this.maxFileSize) { throw new Error(`File content size exceeds maximum allowed size of ${ReceiptProcessor.formatFileSize(this.maxFileSize)}`); } const base64Data = fileBuffer.toString('base64'); return { contents: [ { uri, mimeType, text: base64Data, }, ], }; } /** * Process resource as receipt for expense creation */ async processReceiptResource(resourceUri) { const resourceData = await this.readResource({ uri: resourceUri }); const content = resourceData.contents[0]; if (!content.text || !content.mimeType) { throw new Error('Invalid resource content for receipt processing'); } const filePath = decodeURIComponent(resourceUri.replace('file://', '')); const fileName = path.basename(filePath); return { receipt: content.text, receiptFileType: content.mimeType, fileName, }; } /** * Scan directory for image files with security validation */ async scanDirectoryForImages(directory, resources) { try { // SECURITY: Validate directory path first const validatedDirectory = this.validateFilePath(directory); const entries = await fs.readdir(validatedDirectory, { withFileTypes: true, }); for (const entry of entries) { if (entry.isFile()) { const extension = path.extname(entry.name).toLowerCase(); const mimeType = this.getMimeTypeFromExtension(extension); if (mimeType) { const fullPath = path.resolve(validatedDirectory, entry.name); // SECURITY: Validate each file path try { this.validateFilePath(fullPath); resources.push({ uri: `file://${fullPath}`, name: entry.name, description: `Receipt image: ${entry.name}`, mimeType, }); } catch (error) { // Skip files that fail validation continue; } } } } } catch (error) { // Directory access error, skip silently } } /** * Get MIME type from file extension (ClickTime-compatible only) */ getMimeTypeFromExtension(extension) { const mimeTypes = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.bmp': 'image/bmp', '.pdf': 'application/pdf', }; return mimeTypes[extension.toLowerCase()] || null; } /** * Add allowed directory for file access */ addAllowedDirectory(directory) { // Check cache first to avoid redundant path resolution let resolvedDir = this.directoryCache.get(directory); if (!resolvedDir) { // SECURITY: Normalize and resolve the directory path when adding resolvedDir = path.resolve(path.normalize(directory)); this.directoryCache.set(directory, resolvedDir); } if (!this.allowedDirectories.includes(resolvedDir)) { this.allowedDirectories.push(resolvedDir); } } /** * Get user-friendly instructions for file access */ getFileAccessInstructions() { return ` To use receipt images with ClickTime expenses: 1. Save your receipt image anywhere in your accessible directories: ${this.allowedDirectories.map((dir) => ` • ${dir}`).join('\n')} 2. Reference the full path in your request: Example: "Create expense from file:///Users/john/Downloads/receipt.jpg for $25.50" Example: "Create expense from file:///Users/john/Pictures/receipt.png for $35.00" 3. ClickTime supported formats: JPG, PNG, GIF, BMP, PDF 4. Maximum file size: ${ReceiptProcessor.formatFileSize(this.maxFileSize)} (ClickTime API limit) By default, you have access to your entire home directory. You can restrict this by configuring specific allowed directories if desired. `; } }