Skip to content

Instantly share code, notes, and snippets.

@Tushkiz
Created June 12, 2025 04:51
Show Gist options
  • Save Tushkiz/17eea2042e879ab848589a5f3962bc80 to your computer and use it in GitHub Desktop.
Save Tushkiz/17eea2042e879ab848589a5f3962bc80 to your computer and use it in GitHub Desktop.
Node.js VM Script Isolation

When building a Node.js playground that executes user code, security and stability are critical concerns. Here's a comprehensive approach to isolate client scripts and prevent them from bringing down your server:

1. VM Context Isolation

const vm = require('node:vm');

function createSafeContext() {
  const context = {
    console: {
      log: (...args) => console.log('[USER]', ...args),
      error: (...args) => console.error('[USER]', ...args),
    },
    setTimeout: (fn, delay) => setTimeout(fn, Math.min(delay, 5000)), // Max 5s
    setInterval: null, // Disable intervals
    clearTimeout,
    // Add other safe globals as needed
  };
  
  return vm.createContext(context);
}

2. Comprehensive Error Handling

async function executeUserCode(code, timeout = 5000) {
  const context = createSafeContext();
  
  try {
    const script = new vm.Script(code, {
      filename: 'user-script.js',
      timeout: timeout,
    });
    
    const result = await Promise.race([
      new Promise((resolve, reject) => {
        try {
          const output = script.runInContext(context, {
            timeout: timeout,
            breakOnSigint: true,
          });
          resolve(output);
        } catch (error) {
          reject(error);
        }
      }),
      new Promise((_, reject) => 
        setTimeout(() => reject(new Error('Execution timeout')), timeout)
      )
    ]);
    
    return { success: true, result };
  } catch (error) {
    return { 
      success: false, 
      error: error.message,
      type: error.constructor.name 
    };
  }
}

3. Process-Level Protection

For maximum isolation, consider using child processes:

const { Worker } = require('worker_threads');
const path = require('path');

function executeInWorker(code) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path.join(__dirname, 'code-executor.js'), {
      workerData: { code },
      resourceLimits: {
        maxOldGenerationSizeMb: 50, // Limit memory
        maxYoungGenerationSizeMb: 10,
      }
    });
    
    const timeout = setTimeout(() => {
      worker.terminate();
      reject(new Error('Worker timeout'));
    }, 5000);
    
    worker.on('message', (result) => {
      clearTimeout(timeout);
      resolve(result);
    });
    
    worker.on('error', (error) => {
      clearTimeout(timeout);
      reject(error);
    });
    
    worker.on('exit', (code) => {
      clearTimeout(timeout);
      if (code !== 0) {
        reject(new Error(`Worker exited with code ${code}`));
      }
    });
  });
}

Create code-executor.js:

const { parentPort, workerData } = require('worker_threads');
const vm = require('node:vm');

try {
  const context = vm.createContext({
    console: {
      log: (...args) => parentPort.postMessage({ type: 'log', data: args }),
    }
  });
  
  const script = new vm.Script(workerData.code);
  const result = script.runInContext(context, { timeout: 3000 });
  
  parentPort.postMessage({ success: true, result });
} catch (error) {
  parentPort.postMessage({ 
    success: false, 
    error: error.message,
    stack: error.stack 
  });
}

4. Global Exception Handling

Protect your main process with global handlers:

// Catch uncaught exceptions
process.on('uncaughtException', (error) => {
  console.error('Uncaught Exception:', error);
  // Log the error but don't exit
  // Consider implementing alerting here
});

// Catch unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // Log the error but don't exit
});

// For graceful shutdown
process.on('SIGTERM', () => {
  console.log('SIGTERM received, shutting down gracefully');
  // Cleanup and exit
  process.exit(0);
});

5. Additional Security Measures

// Sanitize code before execution
function sanitizeCode(code) {
  // Block dangerous patterns
  const dangerousPatterns = [
    /require\s*\(/g,
    /import\s+/g,
    /process\./g,
    /global\./g,
    /__dirname/g,
    /__filename/g,
    /Buffer\./g,
  ];
  
  for (const pattern of dangerousPatterns) {
    if (pattern.test(code)) {
      throw new Error('Code contains forbidden patterns');
    }
  }
  
  return code;
}

// Rate limiting per user/IP
const rateLimiter = new Map();

function checkRateLimit(userId, maxRequests = 10, windowMs = 60000) {
  const now = Date.now();
  const userRequests = rateLimiter.get(userId) || [];
  
  // Remove old requests
  const validRequests = userRequests.filter(time => now - time < windowMs);
  
  if (validRequests.length >= maxRequests) {
    throw new Error('Rate limit exceeded');
  }
  
  validRequests.push(now);
  rateLimiter.set(userId, validRequests);
}

6. Express.js Integration Example

const express = require('express');
const app = express();

app.use(express.json({ limit: '10kb' })); // Limit payload size

app.post('/execute', async (req, res) => {
  try {
    const { code, userId } = req.body;
    
    // Rate limiting
    checkRateLimit(userId);
    
    // Sanitize input
    const sanitizedCode = sanitizeCode(code);
    
    // Execute safely
    const result = await executeInWorker(sanitizedCode);
    
    res.json(result);
  } catch (error) {
    console.error('Execution error:', error);
    res.status(400).json({ 
      success: false, 
      error: error.message 
    });
  }
});

// Global error handler
app.use((error, req, res, next) => {
  console.error('Unhandled error:', error);
  res.status(500).json({ error: 'Internal server error' });
});

Key Recommendations:

  1. Always use timeouts - Prevent infinite loops
  2. Limit memory usage - Use worker resource limits
  3. Sanitize input - Block dangerous Node.js APIs
  4. Use worker threads/processes - Complete isolation
  5. Implement rate limiting - Prevent abuse
  6. Log everything - Monitor for attacks
  7. Never trust user input - Validate and sanitize
  8. Consider containers - Docker for ultimate isolation

This approach provides multiple layers of protection to ensure user code cannot bring down your server while still providing a functional playground environment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment