Skip to main content
Glama
security.ts10.6 kB
import crypto from 'crypto'; import { z } from 'zod'; /** * Security utilities for the ClickUp MCP Server */ // Rate limiting configuration export interface RateLimitConfig { windowMs: number; maxRequests: number; } // Default rate limits export const DEFAULT_RATE_LIMITS: Record<string, RateLimitConfig> = { webhook: { windowMs: 60000, maxRequests: 100 }, // 100 requests per minute api: { windowMs: 60000, maxRequests: 1000 }, // 1000 requests per minute upload: { windowMs: 60000, maxRequests: 10 } // 10 uploads per minute }; // Rate limiter implementation class RateLimiter { private requests: Map<string, number[]> = new Map(); isAllowed(key: string, config: RateLimitConfig): boolean { const now = Date.now(); const windowStart = now - config.windowMs; // Get existing requests for this key const keyRequests = this.requests.get(key) || []; // Filter out old requests const recentRequests = keyRequests.filter(time => time > windowStart); // Check if under limit if (recentRequests.length >= config.maxRequests) { return false; } // Add current request recentRequests.push(now); this.requests.set(key, recentRequests); return true; } reset(key?: string): void { if (key) { this.requests.delete(key); } else { this.requests.clear(); } } } export const rateLimiter = new RateLimiter(); /** * Validate and sanitize API token */ export const validateApiToken = (token: string): { isValid: boolean; error?: string } => { if (!token) { return { isValid: false, error: 'API token is required' }; } if (typeof token !== 'string') { return { isValid: false, error: 'API token must be a string' }; } if (token.length < 10) { return { isValid: false, error: 'API token appears to be too short' }; } if (token.length > 200) { return { isValid: false, error: 'API token appears to be too long' }; } // Check for suspicious patterns if (token.includes(' ') || token.includes('\n') || token.includes('\t')) { return { isValid: false, error: 'API token contains invalid characters' }; } return { isValid: true }; }; /** * Sanitize user input to prevent injection attacks */ export const sanitizeInput = (input: any): any => { if (typeof input === 'string') { // Remove potentially dangerous characters return input .replace(/[<>]/g, '') // Remove HTML tags .replace(/javascript:/gi, '') // Remove javascript: protocol .replace(/on\w+=/gi, '') // Remove event handlers .trim(); } if (Array.isArray(input)) { return input.map(sanitizeInput); } if (input && typeof input === 'object') { const sanitized: any = {}; for (const [key, value] of Object.entries(input)) { sanitized[sanitizeInput(key)] = sanitizeInput(value); } return sanitized; } return input; }; /** * Validate webhook signature with timing-safe comparison */ export const validateWebhookSignature = ( payload: string, signature: string, secret: string ): { isValid: boolean; error?: string } => { try { if (!payload || !signature || !secret) { return { isValid: false, error: 'Missing required parameters for signature validation' }; } // Generate expected signature const expectedSignature = crypto .createHmac('sha256', secret) .update(payload, 'utf8') .digest('hex'); // Extract signature from header (remove 'sha256=' prefix if present) const receivedSignature = signature.replace(/^sha256=/, ''); // Validate signature format if (!/^[a-f0-9]{64}$/i.test(receivedSignature)) { return { isValid: false, error: 'Invalid signature format' }; } // Timing-safe comparison const isValid = crypto.timingSafeEqual( Buffer.from(expectedSignature, 'hex'), Buffer.from(receivedSignature, 'hex') ); return { isValid }; } catch (error) { return { isValid: false, error: `Signature validation error: ${error instanceof Error ? error.message : 'Unknown error'}` }; } }; /** * Validate file upload security */ export const validateFileUpload = ( filename: string, mimetype?: string, size?: number ): { isValid: boolean; errors: string[] } => { const errors: string[] = []; // Validate filename if (!filename || typeof filename !== 'string') { errors.push('Filename is required and must be a string'); } else { // Check for path traversal attempts if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { errors.push('Filename contains invalid path characters'); } // Check for dangerous extensions const dangerousExtensions = [ '.exe', '.bat', '.cmd', '.com', '.pif', '.scr', '.vbs', '.js', '.jar', '.php', '.asp', '.aspx', '.jsp', '.sh', '.ps1', '.py', '.rb' ]; const extension = filename.toLowerCase().split('.').pop(); if (extension && dangerousExtensions.includes(`.${extension}`)) { errors.push('File type not allowed for security reasons'); } // Check filename length if (filename.length > 255) { errors.push('Filename too long (max 255 characters)'); } // Check for null bytes if (filename.includes('\0')) { errors.push('Filename contains null bytes'); } } // Validate mimetype if provided if (mimetype) { const allowedMimetypes = [ // Images 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', // Documents 'application/pdf', 'text/plain', 'text/csv', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // Archives 'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed', // Media 'video/mp4', 'video/webm', 'audio/mp3', 'audio/wav', 'audio/ogg' ]; if (!allowedMimetypes.includes(mimetype)) { errors.push(`Mimetype '${mimetype}' not allowed`); } } // Validate file size if provided (max 100MB) if (size !== undefined) { const maxSize = 100 * 1024 * 1024; // 100MB if (size > maxSize) { errors.push(`File size too large (max ${maxSize} bytes)`); } if (size < 0) { errors.push('Invalid file size'); } } return { isValid: errors.length === 0, errors }; }; /** * Validate URL for security */ export const validateUrl = (url: string): { isValid: boolean; error?: string } => { try { const parsedUrl = new URL(url); // Only allow HTTP and HTTPS if (!['http:', 'https:'].includes(parsedUrl.protocol)) { return { isValid: false, error: 'Only HTTP and HTTPS URLs are allowed' }; } // Block localhost and private IPs for security const hostname = parsedUrl.hostname.toLowerCase(); if ( hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.16.') || hostname.startsWith('172.17.') || hostname.startsWith('172.18.') || hostname.startsWith('172.19.') || hostname.startsWith('172.2') || hostname.startsWith('172.30.') || hostname.startsWith('172.31.') ) { return { isValid: false, error: 'Private and localhost URLs are not allowed' }; } return { isValid: true }; } catch (error) { return { isValid: false, error: 'Invalid URL format' }; } }; /** * Generate secure random string */ export const generateSecureToken = (length: number = 32): string => { return crypto.randomBytes(length).toString('hex'); }; /** * Hash sensitive data */ export const hashSensitiveData = (data: string, salt?: string): string => { const actualSalt = salt || crypto.randomBytes(16).toString('hex'); return crypto.pbkdf2Sync(data, actualSalt, 10000, 64, 'sha512').toString('hex'); }; /** * Validate environment variables */ export const validateEnvironment = (): { isValid: boolean; errors: string[] } => { const errors: string[] = []; // Check required environment variables const requiredVars = ['CLICKUP_API_TOKEN']; for (const varName of requiredVars) { const value = process.env[varName]; if (!value) { errors.push(`Missing required environment variable: ${varName}`); } else { // Validate API token format if (varName === 'CLICKUP_API_TOKEN') { const validation = validateApiToken(value); if (!validation.isValid) { errors.push(`Invalid ${varName}: ${validation.error}`); } } } } return { isValid: errors.length === 0, errors }; }; /** * Security headers for HTTP responses */ export const getSecurityHeaders = (): Record<string, string> => { return { 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'X-XSS-Protection': '1; mode=block', 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', 'Content-Security-Policy': "default-src 'self'", 'Referrer-Policy': 'strict-origin-when-cross-origin' }; }; /** * Log security events */ export const logSecurityEvent = ( event: string, details: Record<string, any>, level: 'info' | 'warn' | 'error' = 'info' ): void => { const timestamp = new Date().toISOString(); const logEntry = { timestamp, event, level, details: sanitizeInput(details) }; // In production, this should go to a proper logging system console.error(`[SECURITY ${level.toUpperCase()}] ${timestamp}: ${event}`, logEntry); }; /** * Validate MCP tool parameters */ export const validateMcpParameters = ( schema: z.ZodSchema, params: any ): { isValid: boolean; data?: any; errors?: string[] } => { try { // Sanitize input first const sanitizedParams = sanitizeInput(params); // Validate with schema const data = schema.parse(sanitizedParams); return { isValid: true, data }; } catch (error) { if (error instanceof z.ZodError) { const errors = error.errors.map(err => `${err.path.join('.')}: ${err.message}`); return { isValid: false, errors }; } return { isValid: false, errors: [`Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`] }; } };

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/Chykalophia/ClickUp-MCP-Server---Enhanced'

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