UNPKG

@noanswer/context-compose

Version:

Orchestrate complex AI interactions with Context Compose. A powerful CLI and server for building, validating, and managing context for large language models using the Model Context Protocol (MCP).

231 lines 7.37 kB
import { isAbsolute, normalize, relative, resolve } from 'node:path'; import { z } from 'zod'; /** * Security utilities for path validation and input sanitization */ /** * Path traversal attack prevention */ export class PathValidator { allowedBasePaths; constructor(allowedBasePaths = []) { this.allowedBasePaths = new Set(allowedBasePaths.map((path) => resolve(normalize(path)))); } /** * Validate that a path is safe and within allowed directories */ validatePath(inputPath, basePath) { if (!inputPath || typeof inputPath !== 'string') { throw new Error('Invalid path: path must be a non-empty string'); } // Normalize and resolve the path const normalizedPath = normalize(inputPath); // Check for path traversal attempts if (normalizedPath.includes('..')) { throw new Error('Invalid path: path traversal detected'); } // If basePath is provided, ensure the path is within it if (basePath) { const resolvedBasePath = resolve(normalize(basePath)); const resolvedInputPath = isAbsolute(normalizedPath) ? resolve(normalizedPath) : resolve(resolvedBasePath, normalizedPath); const relativePath = relative(resolvedBasePath, resolvedInputPath); if (relativePath.startsWith('..') || isAbsolute(relativePath)) { throw new Error(`Invalid path: path must be within ${basePath}`); } return resolvedInputPath; } // Check against allowed base paths if configured if (this.allowedBasePaths.size > 0) { const resolvedPath = resolve(normalizedPath); const isAllowed = Array.from(this.allowedBasePaths).some((basePath) => { const relativePath = relative(basePath, resolvedPath); return !relativePath.startsWith('..') && !isAbsolute(relativePath); }); if (!isAllowed) { throw new Error('Invalid path: path not within allowed directories'); } } return resolve(normalizedPath); } /** * Validate context compose specific paths */ validateContextPath(inputPath, projectRoot) { const contextDir = resolve(projectRoot, '.contextcompose'); return this.validatePath(inputPath, contextDir); } /** * Add allowed base path */ addAllowedPath(basePath) { this.allowedBasePaths.add(resolve(normalize(basePath))); } } /** * Input validation schemas */ export const SecuritySchemas = { // Safe filename (no path separators, no special chars) safeFilename: z .string() .min(1) .max(255) .regex(/^[a-zA-Z0-9._-]+$/, 'Filename contains invalid characters'), // Context name validation contextName: z .string() .min(1) .max(100) .regex(/^[a-zA-Z0-9-_]+$/, 'Context name contains invalid characters'), // Project root path validation projectRoot: z .string() .min(1) .refine((path) => !path.includes('..'), 'Project root cannot contain path traversal'), // YAML file extension yamlFile: z .string() .regex(/\.ya?ml$/i, 'File must have .yaml or .yml extension'), }; /** * Sanitize string input by removing dangerous characters */ export function sanitizeString(input, maxLength = 1000) { if (typeof input !== 'string') { throw new Error('Input must be a string'); } return input .slice(0, maxLength) .replace(/[\p{Cc}]/gu, '') // Remove control characters using Unicode property .replace(/<[^>]*>/g, '') // Remove HTML/XML tags completely .replace(/[<>]/g, '') // Remove remaining angle brackets .trim(); } /** * Sanitize filename */ export function sanitizeFilename(filename) { return filename .replace(/[^a-zA-Z0-9._-]/g, '_') .replace(/_{2,}/g, '_') .slice(0, 255); } /** * Validate and sanitize context name */ export function sanitizeContextName(contextName) { if (!contextName || typeof contextName !== 'string') { throw new Error('Context name must be a non-empty string'); } const sanitized = contextName .toLowerCase() .replace(/[^a-z0-9-_]/g, '-') .replace(/-{2,}/g, '-') .replace(/^-+|-+$/g, '') // Remove leading/trailing dashes .slice(0, 100); if (!sanitized || sanitized.length === 0) { throw new Error('Context name cannot be empty after sanitization'); } return sanitized; } /** * Environment variable security */ const sensitiveKeys = new Set([ 'password', 'secret', 'key', 'token', 'api_key', 'private_key', 'auth', 'credential', 'cert', 'ssl', 'tls', ]); /** * Check if environment variable key is sensitive */ export function isSensitiveKey(key) { const lowerKey = key.toLowerCase(); return Array.from(sensitiveKeys).some((sensitive) => lowerKey.includes(sensitive)); } /** * Mask sensitive environment variable value */ export function maskSensitiveValue(key, value) { if (isSensitiveKey(key)) { if (value.length <= 4) { return '***'; } // Show first 2 and last 2 characters with asterisks for the middle const middleLength = value.length - 4; return `${value.slice(0, 2)}${'*'.repeat(middleLength)}${value.slice(-2)}`; } return value; } /** * Get safe environment variables for logging */ export function getSafeEnvVars() { const safeVars = {}; for (const [key, value] of Object.entries(process.env)) { if (value !== undefined) { safeVars[key] = maskSensitiveValue(key, value); } } return safeVars; } /** * Rate limiting for security */ export class RateLimiter { requests = new Map(); maxRequests; windowMs; constructor(maxRequests = 100, windowMs = 60000) { this.maxRequests = maxRequests; this.windowMs = windowMs; } /** * Check if request is allowed */ isAllowed(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; } validRequests.push(now); this.requests.set(identifier, validRequests); return true; } /** * Clear old entries periodically */ cleanup() { const now = Date.now(); for (const [identifier, requests] of this.requests) { const validRequests = requests.filter((time) => now - time < this.windowMs); if (validRequests.length === 0) { this.requests.delete(identifier); } else { this.requests.set(identifier, validRequests); } } } } /** * Global instances */ export const defaultPathValidator = new PathValidator(); export const defaultRateLimiter = new RateLimiter(); // Cleanup rate limiter every 5 minutes setInterval(() => defaultRateLimiter.cleanup(), 5 * 60 * 1000); //# sourceMappingURL=security.js.map