@sirmrmarty/n8n-nodes-tmux-orchestrator
Version:
n8n nodes for orchestrating Claude AI agents through tmux sessions
370 lines (318 loc) • 10.1 kB
text/typescript
/**
* 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);
}
}
}