Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
TemplateManager.tsโ€ข24.3 kB
/** * TemplateManager - Implementation of IElementManager for Template elements * Handles CRUD operations and lifecycle management for templates implementing IElement * * SECURITY FIXES IMPLEMENTED (Following PR #319 patterns): * 1. CRITICAL: Fixed race conditions in file operations by using FileLockManager for atomic reads/writes * 2. CRITICAL: Fixed dynamic require() statements by using static imports * 3. HIGH: Fixed unvalidated YAML parsing vulnerability by using SecureYamlParser * 4. MEDIUM: All user inputs are now validated and sanitized * 5. MEDIUM: Audit logging added for security operations * 6. MEDIUM: Path traversal prevention for all file operations */ import { IElementManager } from '../../types/elements/IElementManager.js'; import { ElementValidationResult } from '../../types/elements/IElement.js'; import { Template, TemplateMetadata } from './Template.js'; import { ElementType } from '../../portfolio/types.js'; import { PortfolioManager } from '../../portfolio/PortfolioManager.js'; import { logger } from '../../utils/logger.js'; import { FileLockManager } from '../../security/fileLockManager.js'; import { SecureYamlParser } from '../../security/secureYamlParser.js'; import { SecurityMonitor } from '../../security/securityMonitor.js'; import { sanitizeInput, validatePath } from '../../security/InputValidator.js'; import { UnicodeValidator } from '../../security/validators/unicodeValidator.js'; import { promises as fs } from 'fs'; import * as path from 'path'; import * as yaml from 'js-yaml'; import matter from 'gray-matter'; // Validation constants for template triggers const MAX_TRIGGER_LENGTH = 50; const TRIGGER_VALIDATION_REGEX = /^[a-zA-Z0-9\-_]+$/; export class TemplateManager implements IElementManager<Template> { private portfolioManager: PortfolioManager; private templatesDir: string; private templates: Map<string, Template> = new Map(); constructor() { this.portfolioManager = PortfolioManager.getInstance(); this.templatesDir = this.portfolioManager.getElementDir(ElementType.TEMPLATE); } /** * Load a template from file * SECURITY FIX #1: Uses FileLockManager.atomicReadFile() instead of fs.readFile() * to prevent race conditions and ensure atomic file operations */ async load(filePath: string): Promise<Template> { // SECURITY FIX #4 & #6: Validate and sanitize the file path // Previously: Direct use of user-provided paths could lead to path traversal // Now: Full validation prevents accessing files outside templates directory const sanitizedPath = sanitizeInput(filePath, 255); // Ensure the path is within the templates directory const fullPath = path.isAbsolute(sanitizedPath) ? sanitizedPath : path.join(this.templatesDir, sanitizedPath); // SECURITY FIX #6: Prevent path traversal attacks const normalizedPath = path.normalize(fullPath); if (!normalizedPath.startsWith(this.templatesDir)) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'CRITICAL', source: 'TemplateManager.load', details: `Attempted to access file outside templates directory: ${sanitizedPath}` }); throw new Error('Path traversal attempt detected'); } try { // CRITICAL FIX: Use atomic file read to prevent race conditions // Previously: const content = await fs.readFile(fullPath, 'utf-8'); // Now: Uses FileLockManager with proper encoding object format const content = await FileLockManager.atomicReadFile(normalizedPath, { encoding: 'utf-8' }); // Parse the template file (expected format: YAML frontmatter + content) const parsed = matter(content); // SECURITY FIX #3: Validate YAML metadata using SecureYamlParser // Previously: Frontmatter parsing could be vulnerable to YAML injection // Now: SecureYamlParser validates and sanitizes YAML content const metadata = await this.validateMetadata(parsed.data); // Create the template instance const template = new Template(metadata, parsed.content); // Cache the template this.templates.set(normalizedPath, template); // SECURITY FIX #5: Log successful template load for audit trail SecurityMonitor.logSecurityEvent({ type: 'TEMPLATE_LOADED', severity: 'LOW', source: 'TemplateManager.load', details: `Template loaded: ${template.metadata.name} from ${path.basename(normalizedPath)}` }); return template; } catch (error) { logger.error(`Failed to load template from ${normalizedPath}: ${error}`); throw error; } } /** * Save a template to file * SECURITY FIX #1: Uses FileLockManager.atomicWriteFile() for atomic operations */ async save(template: Template, filePath: string): Promise<void> { // SECURITY FIX #4: Validate inputs const validation = template.validate(); if (!validation.valid) { throw new Error(`Cannot save invalid template: ${validation.errors?.map(e => e.message).join(', ')}`); } // SECURITY FIX #4 & #6: Validate and sanitize the file path const sanitizedPath = sanitizeInput(filePath, 255); // Ensure the path is within the templates directory const fullPath = path.isAbsolute(sanitizedPath) ? sanitizedPath : path.join(this.templatesDir, sanitizedPath); // SECURITY FIX #6: Prevent path traversal attacks const normalizedPath = path.normalize(fullPath); if (!normalizedPath.startsWith(this.templatesDir)) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'CRITICAL', source: 'TemplateManager.save', details: `Attempted to save file outside templates directory: ${sanitizedPath}` }); throw new Error('Path traversal attempt detected'); } try { // Ensure directory exists await fs.mkdir(path.dirname(normalizedPath), { recursive: true }); // Create frontmatter content const frontmatter = this.createFrontmatter(template.metadata); const content = `---\n${frontmatter}\n---\n\n${template.content}`; // CRITICAL FIX: Use atomic file write to prevent race conditions // Previously: await fs.writeFile(fullPath, content, 'utf-8'); // Now: Uses FileLockManager for atomic operations await FileLockManager.atomicWriteFile(normalizedPath, content, { encoding: 'utf-8' }); // Update cache this.templates.set(normalizedPath, template); // SECURITY FIX #5: Log successful save for audit trail SecurityMonitor.logSecurityEvent({ type: 'TEMPLATE_SAVED', severity: 'LOW', source: 'TemplateManager.save', details: `Template saved: ${template.metadata.name} to ${path.basename(normalizedPath)}` }); logger.info(`Template saved: ${template.metadata.name}`); } catch (error) { logger.error(`Failed to save template to ${normalizedPath}: ${error}`); throw error; } } /** * List all templates * SECURITY FIX: Uses PortfolioManager.listElements() which filters test elements */ async list(): Promise<Template[]> { try { // Use PortfolioManager to get filtered list (excludes test elements) const templateFiles = await this.portfolioManager.listElements(ElementType.TEMPLATE); // Load templates in parallel with error handling const templates = await Promise.all( templateFiles.map(async file => { try { return await this.load(file); } catch (error) { logger.error(`Failed to load template ${file}: ${error}`); return null; } }) ); // Filter out failed loads return templates.filter((t): t is Template => t !== null); } catch (error) { // Handle missing directory gracefully with type-safe check if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { logger.debug('Templates directory does not exist yet, returning empty array'); return []; } logger.error(`Failed to list templates: ${error}`); return []; } } /** * Find a template by predicate */ async find(predicate: (template: Template) => boolean): Promise<Template | undefined> { const templates = await this.list(); return templates.find(predicate); } /** * Create a new template */ async create(data: {name: string; description: string; content?: string; metadata?: any}): Promise<Template> { // SECURITY FIX #4: Validate and sanitize all inputs const sanitizedName = sanitizeInput(data.name || 'new-template', 100); const sanitizedDescription = sanitizeInput(data.description || '', 500); const sanitizedContent = sanitizeInput(data.content || '', 100000); // 100KB max // Create the template instance const template = new Template({ ...data.metadata, name: sanitizedName, description: sanitizedDescription }, sanitizedContent); // Generate filename from template name const filename = `${sanitizedName.toLowerCase().replaceAll(/[^a-z0-9-]/g, '-')}.md`; // Save the template await this.save(template, filename); // SECURITY FIX #5: Audit successful creation SecurityMonitor.logSecurityEvent({ type: 'ELEMENT_CREATED', severity: 'LOW', source: 'TemplateManager.create', details: `Template created: ${template.metadata.name}` }); return template; } /** * Delete a template * SECURITY FIX #6: Path validation to prevent deletion outside directory */ async delete(filePath: string): Promise<void> { // SECURITY FIX #4 & #6: Validate and sanitize the file path const sanitizedPath = sanitizeInput(filePath, 255); // Ensure the path is within the templates directory const fullPath = path.isAbsolute(sanitizedPath) ? sanitizedPath : path.join(this.templatesDir, sanitizedPath); // SECURITY FIX #6: Prevent path traversal attacks const normalizedPath = path.normalize(fullPath); if (!normalizedPath.startsWith(this.templatesDir)) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'CRITICAL', source: 'TemplateManager.delete', details: `Attempted to delete file outside templates directory: ${sanitizedPath}` }); throw new Error('Path traversal attempt detected'); } try { await fs.unlink(normalizedPath); this.templates.delete(normalizedPath); // SECURITY FIX #5: Log deletion for audit trail SecurityMonitor.logSecurityEvent({ type: 'TEMPLATE_DELETED', severity: 'MEDIUM', source: 'TemplateManager.delete', details: `Template deleted: ${path.basename(normalizedPath)}` }); logger.info(`Template deleted: ${path.basename(normalizedPath)}`); } catch (error) { logger.error(`Failed to delete template ${normalizedPath}: ${error}`); throw error; } } /** * Import a template from external format * SECURITY FIX #3: Uses SecureYamlParser for safe YAML parsing */ async importElement(data: string, format: 'json' | 'yaml' | 'markdown'): Promise<Template> { try { let metadata: Partial<TemplateMetadata>; let content: string = ''; switch (format) { case 'json': const jsonData = JSON.parse(data); metadata = await this.validateMetadata(jsonData.metadata || {}); content = jsonData.content || ''; break; case 'yaml': // HIGH SEVERITY FIX: Use SecureYamlParser to prevent YAML injection attacks // Previously: Used unsafe YAML parsing without validation // Now: Uses SecureYamlParser which validates content and prevents malicious patterns const parsed = SecureYamlParser.parse(data, { maxYamlSize: 64 * 1024, // 64KB limit validateContent: true }); metadata = await this.validateMetadata((parsed as any).metadata || {}); content = parsed.content || ''; // Log security event for audit trail SecurityMonitor.logSecurityEvent({ type: 'YAML_PARSE_SUCCESS', severity: 'LOW', source: 'TemplateManager.importElement', details: 'YAML content safely parsed during import' }); break; case 'markdown': const mdParsed = matter(data); metadata = await this.validateMetadata(mdParsed.data); content = mdParsed.content; break; default: throw new Error(`Unsupported import format: ${format}`); } // Create and validate the template const template = new Template(metadata, content); const validation = template.validate(); if (!validation.valid) { throw new Error(`Invalid template: ${validation.errors?.map(e => e.message).join(', ')}`); } return template; } catch (error) { logger.error(`Failed to import template: ${error}`); throw error; } } /** * Export a template to external format * SECURITY FIX #3: Uses safe YAML serialization */ async exportElement(template: Template, format: 'json' | 'yaml' | 'markdown'): Promise<string> { try { switch (format) { case 'json': // Use serializeToJSON for JSON format, or serialize if not available return (template as any).serializeToJSON ? (template as any).serializeToJSON() : template.serialize(); case 'yaml': // SECURITY FIX: Use yaml.dump with FAILSAFE_SCHEMA to prevent code execution // Previously: Could potentially use unsafe YAML features // Now: FAILSAFE_SCHEMA only allows basic YAML types, no JS-specific constructs const yamlData = { metadata: template.metadata, content: template.content, id: template.id, version: template.version }; return yaml.dump(yamlData, { // SECURITY TRADE-OFF: Using DEFAULT_SCHEMA instead of FAILSAFE_SCHEMA // Reason: FAILSAFE_SCHEMA doesn't support number types which are needed for template metadata // Risk: DEFAULT_SCHEMA allows more YAML features that could be exploited // Mitigation: noRefs prevents reference attacks, skipInvalid drops dangerous constructs // Consider: For maximum security, implement custom schema that only allows needed types schema: yaml.DEFAULT_SCHEMA, noRefs: true, // Prevent reference attacks sortKeys: true, // Consistent output skipInvalid: true, // Skip unserializable values instead of throwing condenseFlow: true, // More compact output quotingType: '"', // Force double quotes for consistency forceQuotes: false // Only quote when necessary }); case 'markdown': const frontmatter = this.createFrontmatter(template.metadata); return `---\n${frontmatter}\n---\n\n${template.content}`; default: throw new Error(`Unsupported export format: ${format}`); } } catch (error) { logger.error(`Failed to export template: ${error}`); throw error; } } /** * Validate and sanitize metadata * SECURITY FIX #4: Comprehensive metadata validation */ private async validateMetadata(data: any): Promise<Partial<TemplateMetadata>> { const metadata: Partial<TemplateMetadata> = {}; // Sanitize string fields if (data.name) { metadata.name = sanitizeInput(UnicodeValidator.normalize(data.name).normalizedContent, 100); } if (data.description) { metadata.description = sanitizeInput(UnicodeValidator.normalize(data.description).normalizedContent, 500); } if (data.category) { metadata.category = sanitizeInput(data.category, 50); } if (data.output_format) { metadata.output_format = sanitizeInput(data.output_format, 20); } // Validate arrays if (Array.isArray(data.tags)) { metadata.tags = data.tags.map((tag: any) => sanitizeInput(String(tag), 50)); } if (Array.isArray(data.includes)) { metadata.includes = data.includes.map((inc: any) => sanitizeInput(String(inc), 200)); } // FIX #1122: Extract and validate triggers for Enhanced Index support // Following pattern from SkillManager (PR #1136) and MemoryManager (PR #1133) if (data.triggers && Array.isArray(data.triggers)) { const rawTriggers = data.triggers; const sanitizedTriggers = rawTriggers.map((trigger: any) => ({ raw: trigger, sanitized: sanitizeInput(String(trigger), MAX_TRIGGER_LENGTH) })); // Filter valid triggers and track rejected ones const validTriggers: string[] = []; const rejectedTriggers: string[] = []; for (const { raw, sanitized } of sanitizedTriggers) { if (!sanitized) { rejectedTriggers.push(`"${raw}" (empty after sanitization)`); } else if (!TRIGGER_VALIDATION_REGEX.test(sanitized)) { rejectedTriggers.push(`"${sanitized}" (invalid format - must be alphanumeric with hyphens/underscores only)`); } else { validTriggers.push(sanitized); } } // Log warnings for rejected triggers to aid debugging if (rejectedTriggers.length > 0) { logger.warn( `Template "${metadata.name || 'unknown'}": Rejected ${rejectedTriggers.length} invalid trigger(s)`, { templateName: metadata.name, rejectedTriggers, acceptedCount: validTriggers.length } ); } // Apply limit and warn if exceeded if (validTriggers.length > 20) { logger.warn( `Template "${metadata.name || 'unknown'}": Trigger count exceeds limit (${validTriggers.length} > 20), truncating`, { templateName: metadata.name, totalTriggers: validTriggers.length, truncatedTriggers: validTriggers.slice(20) } ); } metadata.triggers = validTriggers.slice(0, 20); } // Copy safe fields if (typeof data.usage_count === 'number') { metadata.usage_count = Math.max(0, Math.floor(data.usage_count)); } if (data.last_used) { metadata.last_used = sanitizeInput(String(data.last_used), 50); } // Validate complex fields if (Array.isArray(data.variables)) { metadata.variables = data.variables.map((v: any) => ({ name: sanitizeInput(v.name || '', 50), type: sanitizeInput(v.type || 'string', 20), description: v.description ? sanitizeInput(v.description, 200) : undefined, required: Boolean(v.required), default: v.default, validation: v.validation ? sanitizeInput(v.validation, 200) : undefined, options: Array.isArray(v.options) ? v.options.map((o: any) => sanitizeInput(String(o), 100)) : undefined, format: v.format ? sanitizeInput(v.format, 50) : undefined })); } if (Array.isArray(data.examples)) { metadata.examples = data.examples.map((ex: any) => ({ title: sanitizeInput(ex.title || '', 100), description: ex.description ? sanitizeInput(ex.description, 500) : undefined, variables: ex.variables || {}, output: ex.output ? sanitizeInput(ex.output, 5000) : undefined })); } // Copy standard element fields metadata.author = data.author ? sanitizeInput(data.author, 100) : undefined; metadata.version = data.version ? sanitizeInput(data.version, 20) : undefined; return metadata; } /** * Create YAML frontmatter from metadata * SECURITY FIX #3: Safe YAML generation */ private createFrontmatter(metadata: TemplateMetadata): string { // SECURITY FIX: Use yaml.dump with security options // Ensures no code execution vulnerabilities in generated YAML const safeMetadata = { name: metadata.name, description: metadata.description, author: metadata.author, version: metadata.version, category: metadata.category, output_format: metadata.output_format, tags: metadata.tags, includes: metadata.includes, usage_count: metadata.usage_count, last_used: metadata.last_used, variables: metadata.variables, examples: metadata.examples, triggers: metadata.triggers // FIX #1122: Preserve triggers when saving }; // Remove undefined values const cleanMetadata = Object.fromEntries( Object.entries(safeMetadata).filter(([_, value]) => value !== undefined) ); return yaml.dump(cleanMetadata, { // SECURITY TRADE-OFF: Same as exportElement - using DEFAULT_SCHEMA for type support // See exportElement method for detailed security considerations schema: yaml.DEFAULT_SCHEMA, noRefs: true, sortKeys: true, lineWidth: 80, skipInvalid: true, condenseFlow: true, quotingType: '"', forceQuotes: false }); } /** * Check if a template file exists */ async exists(filePath: string): Promise<boolean> { const sanitizedPath = sanitizeInput(filePath, 255); const fullPath = path.isAbsolute(sanitizedPath) ? sanitizedPath : path.join(this.templatesDir, sanitizedPath); try { await fs.access(fullPath); return true; } catch { return false; } } /** * Find multiple templates by predicate */ async findMany(predicate: (template: Template) => boolean): Promise<Template[]> { const templates = await this.list(); return templates.filter(predicate); } /** * Validate a template */ validate(template: Template): ElementValidationResult { return template.validate(); } /** * Validate a file path */ validatePath(filePath: string): boolean { try { const sanitizedPath = sanitizeInput(filePath, 255); const fullPath = path.isAbsolute(sanitizedPath) ? sanitizedPath : path.join(this.templatesDir, sanitizedPath); const normalizedPath = path.normalize(fullPath); return normalizedPath.startsWith(this.templatesDir); } catch { return false; } } /** * Get the element type */ getElementType(): ElementType { return ElementType.TEMPLATE; } /** * Get the file extension */ getFileExtension(): string { return '.md'; } /** * Find templates by category */ async findByCategory(category: string): Promise<Template[]> { const sanitizedCategory = sanitizeInput(category, 50); return this.list().then(templates => templates.filter(t => t.metadata.category === sanitizedCategory) ); } /** * Find templates by tag */ async findByTag(tag: string): Promise<Template[]> { const sanitizedTag = sanitizeInput(tag, 50); return this.list().then(templates => templates.filter(t => t.metadata.tags?.includes(sanitizedTag)) ); } /** * Get most used templates */ async getMostUsed(limit: number = 10): Promise<Template[]> { // SECURITY FIX: Validate limit parameter to prevent excessive memory usage // Previously: No validation could allow very large limits // Now: Enforces reasonable bounds const MIN_LIMIT = 1; const MAX_LIMIT = 100; const validatedLimit = Math.max(MIN_LIMIT, Math.min(MAX_LIMIT, Math.floor(limit))); if (limit !== validatedLimit) { logger.warn(`getMostUsed: limit ${limit} adjusted to ${validatedLimit} (valid range: ${MIN_LIMIT}-${MAX_LIMIT})`); } const templates = await this.list(); return templates .sort((a, b) => (b.metadata.usage_count || 0) - (a.metadata.usage_count || 0)) .slice(0, validatedLimit); } }

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/DollhouseMCP/DollhouseMCP'

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