UNPKG

@sirmrmarty/n8n-nodes-tmux-orchestrator

Version:

n8n nodes for orchestrating Claude AI agents through tmux sessions

370 lines (318 loc) 10.1 kB
/** * Secure Temporary File Management * Provides secure temporary file operations with proper permissions and cleanup */ import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; import { promisify } from 'util'; import { resourceManager } from './resourceManager'; export interface SecureTempFileOptions { prefix?: string; suffix?: string; directory?: string; mode?: number; maxAge?: number; // milliseconds before auto-cleanup keepOnExit?: boolean; // if false, cleanup on process exit } export interface SecureTempFileInfo { path: string; cleanup: () => Promise<void>; isValid: () => boolean; getStats: () => fs.Stats | null; } export class SecureTempFileManager { private static readonly DEFAULT_TEMP_DIR = process.platform === 'win32' ? path.join(process.env.TEMP || process.env.TMP || 'C:\\tmp', 'n8n-secure') : '/var/tmp/n8n-secure'; private static readonly DEFAULT_MODE = 0o600; // Read/write for owner only private static readonly DEFAULT_DIR_MODE = 0o700; // Full access for owner only private static readonly MAX_TEMP_FILES = 1000; // Prevent DoS private static readonly DEFAULT_MAX_AGE = 30 * 60 * 1000; // 30 minutes private static activeTempFiles = new Map<string, SecureTempFileInfo>(); private static cleanupScheduled = false; /** * Create a secure temporary file with proper permissions */ public static async createSecureTempFile( content: string | Buffer, options: SecureTempFileOptions = {} ): Promise<SecureTempFileInfo> { try { // Validate input if (content == null) { throw new Error('Content cannot be null or undefined'); } if (typeof content === 'string' && content.length > 10 * 1024 * 1024) { throw new Error('Content too large (max 10MB)'); } if (Buffer.isBuffer(content) && content.length > 10 * 1024 * 1024) { throw new Error('Content too large (max 10MB)'); } // Check active file limit if (this.activeTempFiles.size >= this.MAX_TEMP_FILES) { throw new Error('Too many active temporary files - cleanup required'); } // Ensure secure temp directory exists const tempDir = options.directory || this.DEFAULT_TEMP_DIR; await this.ensureSecureDirectory(tempDir); // Generate secure filename const filename = this.generateSecureFilename(options.prefix, options.suffix); const filepath = path.join(tempDir, filename); // Validate the path is within our secure directory const resolvedPath = path.resolve(filepath); const resolvedDir = path.resolve(tempDir); if (!resolvedPath.startsWith(resolvedDir)) { throw new Error('Path traversal attempt detected'); } // Write file with secure permissions const mode = options.mode || this.DEFAULT_MODE; await fs.promises.writeFile(filepath, content, { mode }); // Verify file was created with correct permissions const stats = await fs.promises.stat(filepath); if ((stats.mode & 0o777) !== mode) { await fs.promises.unlink(filepath); throw new Error('Failed to set secure file permissions'); } // Create cleanup function let isValid = true; const cleanup = async (): Promise<void> => { if (!isValid) return; try { await fs.promises.unlink(filepath); this.activeTempFiles.delete(filepath); isValid = false; } catch (error) { // File might already be deleted if (error.code !== 'ENOENT') { console.warn(`Failed to cleanup temp file ${filepath}: ${error.message}`); } isValid = false; } }; // Create file info object const fileInfo: SecureTempFileInfo = { path: filepath, cleanup, isValid: () => isValid, getStats: () => { try { return fs.statSync(filepath); } catch { isValid = false; return null; } } }; // Register for tracking and auto-cleanup this.activeTempFiles.set(filepath, fileInfo); // Schedule auto-cleanup const maxAge = options.maxAge || this.DEFAULT_MAX_AGE; setTimeout(async () => { if (isValid) { await cleanup(); } }, maxAge); // Schedule global cleanup if not already done if (!this.cleanupScheduled) { this.scheduleGlobalCleanup(options.keepOnExit !== true); } return fileInfo; } catch (error) { throw new Error(`Failed to create secure temporary file: ${error.message}`); } } /** * Create a secure temporary directory */ public static async createSecureTempDirectory( options: SecureTempFileOptions = {} ): Promise<{ path: string; cleanup: () => Promise<void> }> { try { // Ensure secure temp directory exists const tempDir = options.directory || this.DEFAULT_TEMP_DIR; await this.ensureSecureDirectory(tempDir); // Generate secure directory name const dirname = this.generateSecureFilename(options.prefix || 'dir', ''); const dirpath = path.join(tempDir, dirname); // Validate the path is within our secure directory const resolvedPath = path.resolve(dirpath); const resolvedDir = path.resolve(tempDir); if (!resolvedPath.startsWith(resolvedDir)) { throw new Error('Path traversal attempt detected'); } // Create directory with secure permissions const mode = options.mode || this.DEFAULT_DIR_MODE; await fs.promises.mkdir(dirpath, { mode }); // Verify directory was created with correct permissions const stats = await fs.promises.stat(dirpath); if ((stats.mode & 0o777) !== mode) { await fs.promises.rmdir(dirpath); throw new Error('Failed to set secure directory permissions'); } // Create cleanup function const cleanup = async (): Promise<void> => { try { await fs.promises.rmdir(dirpath, { recursive: true }); } catch (error) { if (error.code !== 'ENOENT') { console.warn(`Failed to cleanup temp directory ${dirpath}: ${error.message}`); } } }; // Schedule auto-cleanup const maxAge = options.maxAge || this.DEFAULT_MAX_AGE; setTimeout(cleanup, maxAge); return { path: dirpath, cleanup }; } catch (error) { throw new Error(`Failed to create secure temporary directory: ${error.message}`); } } /** * Get a secure temporary directory path (creates if needed) */ public static async getSecureTempDir(customDir?: string): Promise<string> { const tempDir = customDir || this.DEFAULT_TEMP_DIR; await this.ensureSecureDirectory(tempDir); return tempDir; } /** * Clean up all active temporary files */ public static async cleanupAll(): Promise<void> { const cleanupPromises = Array.from(this.activeTempFiles.values()).map( fileInfo => fileInfo.cleanup() ); await Promise.allSettled(cleanupPromises); this.activeTempFiles.clear(); } /** * Get statistics about active temporary files */ public static getStats(): { activeFiles: number; oldestFileAge: number; totalSize: number; } { let totalSize = 0; let oldestFileAge = 0; const now = Date.now(); for (const fileInfo of this.activeTempFiles.values()) { if (fileInfo.isValid()) { const stats = fileInfo.getStats(); if (stats) { totalSize += stats.size; const age = now - stats.birthtimeMs; oldestFileAge = Math.max(oldestFileAge, age); } } } return { activeFiles: this.activeTempFiles.size, oldestFileAge, totalSize }; } /** * Ensure secure directory exists with proper permissions */ private static async ensureSecureDirectory(dirPath: string): Promise<void> { try { // Check if directory exists const stats = await fs.promises.stat(dirPath); if (!stats.isDirectory()) { throw new Error(`Path exists but is not a directory: ${dirPath}`); } // Check permissions const mode = stats.mode & 0o777; if (mode !== this.DEFAULT_DIR_MODE) { // Try to fix permissions await fs.promises.chmod(dirPath, this.DEFAULT_DIR_MODE); } } catch (error) { if (error.code === 'ENOENT') { // Directory doesn't exist, create it await fs.promises.mkdir(dirPath, { recursive: true, mode: this.DEFAULT_DIR_MODE }); } else { throw error; } } } /** * Generate a secure filename with cryptographic randomness */ private static generateSecureFilename(prefix?: string, suffix?: string): string { const timestamp = Date.now(); const randomBytes = crypto.randomBytes(16).toString('hex'); const pid = process.pid; const parts = [ prefix || 'secure', timestamp, pid, randomBytes, suffix || 'tmp' ].filter(Boolean); return parts.join('_'); } /** * Schedule global cleanup for process exit */ private static scheduleGlobalCleanup(cleanupOnExit: boolean): void { if (this.cleanupScheduled) return; this.cleanupScheduled = true; if (cleanupOnExit) { // Cleanup on process exit process.on('exit', () => { // Synchronous cleanup for exit handler for (const fileInfo of this.activeTempFiles.values()) { try { if (fileInfo.isValid() && fs.existsSync(fileInfo.path)) { fs.unlinkSync(fileInfo.path); } } catch (error) { // Ignore errors during exit cleanup } } }); // Cleanup on signals ['SIGTERM', 'SIGINT', 'SIGHUP'].forEach(signal => { process.on(signal, async () => { await this.cleanupAll(); process.exit(0); }); }); } // Periodic cleanup of stale files resourceManager.createInterval(async () => { try { await this.cleanupStaleFiles(); } catch (error) { console.warn('Failed to cleanup stale temporary files:', error.message); } }, 5 * 60 * 1000, 'Secure temp file cleanup'); } /** * Clean up stale temporary files */ private static async cleanupStaleFiles(): Promise<void> { const toDelete = []; for (const [filepath, fileInfo] of this.activeTempFiles.entries()) { if (!fileInfo.isValid()) { toDelete.push(filepath); } else { const stats = fileInfo.getStats(); if (!stats) { // File no longer exists toDelete.push(filepath); } } } // Remove invalid entries for (const filepath of toDelete) { this.activeTempFiles.delete(filepath); } } }