@clicktime/mcp-server
Version:
ClickTime MCP Tech Demo for AI agents to interact with ClickTime API
306 lines (301 loc) • 12.6 kB
JavaScript
// 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.
`;
}
}