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:
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);
}
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
};
}
}
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
});
}
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);
});
// 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);
}
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' });
});
- Always use timeouts - Prevent infinite loops
- Limit memory usage - Use worker resource limits
- Sanitize input - Block dangerous Node.js APIs
- Use worker threads/processes - Complete isolation
- Implement rate limiting - Prevent abuse
- Log everything - Monitor for attacks
- Never trust user input - Validate and sanitize
- 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.