Files
claude-code-templates/cli-tool/src/sandbox-server.js
T
bc4618b072 Merge commit from fork
* Fix unauthenticated command injection (RCE) in Studio server

The `--studio` server (cli-tool/src/sandbox-server.js) bound to 0.0.0.0
with wildcard CORS and no authentication, and passed request-body fields
into spawn(..., { shell: true }). Because shell:true makes Node join the
argv array into a single `sh -c` string, the fields were parsed by the
shell, allowing OS command injection (RCE) via POST /api/execute (prompt)
and POST /api/install-agent (agentName), reachable from the LAN or a
malicious web page (wildcard CORS).

- Remove shell:true from all three spawns so arguments stay discrete argv
  entries; shell metacharacters can no longer inject commands.
- Validate agentName against ^[A-Za-z0-9._/-]+$ before use.
- Bind the server to 127.0.0.1 only (removes the LAN vector).
- Restrict CORS to the local UI origin and reject cross-origin requests
  (removes the drive-by browser vector).

Fixes GHSA-79wm-x847-7cvg.

Co-Authored-By: Claude Opus <noreply@anthropic.com>

* security: Windows-safe spawns + release bump on top of GHSA-79wm-x847-7cvg fix

Builds on @spartan8806's fix (advisory-fix-1). Adds the two pieces that fix
lacked:

- Resolve npx/claude via their .cmd shims on Windows (NPX_CMD/CLAUDE_CMD).
  Without shell:true, bare spawn('npx'/'claude') fails with ENOENT on win32,
  which would break agent install/local execution. This keeps the security fix
  cross-platform.
- Bump version to 1.29.4 (root + cli-tool package.json) and add the 1.29.4
  Security entry to CHANGELOG for the patched release.

Refs GHSA-79wm-x847-7cvg.

---------

Co-authored-by: spartan8806 <spartan8806@users.noreply.github.com>
Co-authored-by: Claude Opus <noreply@anthropic.com>
Co-authored-by: Dani <dan.avila7@gmail.com>
2026-07-14 19:25:34 -04:00

619 lines
23 KiB
JavaScript

#!/usr/bin/env node
const express = require('express');
const path = require('path');
const { spawn } = require('child_process');
const chalk = require('chalk');
const fs = require('fs');
const app = express();
const PORT = process.env.PORT || 3444;
// Platform-aware command names. Now that spawns run without `shell: true`,
// Windows needs the `.cmd` shim to resolve npm-installed executables (bare
// 'npx'/'claude' would fail with ENOENT on win32).
const NPX_CMD = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const CLAUDE_CMD = process.platform === 'win32' ? 'claude.cmd' : 'claude';
// Load .env file from current working directory (where user runs the command)
function loadEnvFile() {
const envPath = path.join(process.cwd(), '.env');
if (fs.existsSync(envPath)) {
console.log(chalk.blue('📄 Loading .env file from:'), chalk.gray(envPath));
const envContent = fs.readFileSync(envPath, 'utf8');
const envVars = envContent.split('\n')
.filter(line => line.trim() && !line.startsWith('#'))
.reduce((acc, line) => {
const [key, ...valueParts] = line.split('=');
if (key && valueParts.length > 0) {
const value = valueParts.join('=').trim().replace(/^["']|["']$/g, ''); // Remove quotes
acc[key.trim()] = value;
}
return acc;
}, {});
// Set environment variables
Object.assign(process.env, envVars);
const hasE2B = !!process.env.E2B_API_KEY;
const hasAnthropic = !!process.env.ANTHROPIC_API_KEY;
console.log(chalk.green('✅ Environment variables loaded:'));
console.log(chalk.gray(` • E2B_API_KEY: ${hasE2B ? 'Found' : 'Missing'}`));
console.log(chalk.gray(` • ANTHROPIC_API_KEY: ${hasAnthropic ? 'Found' : 'Missing'}`));
return hasE2B && hasAnthropic;
} else {
console.log(chalk.yellow('⚠️ No .env file found in:'), chalk.gray(envPath));
return false;
}
}
// Load environment variables on startup
const hasApiKeys = loadEnvFile();
// CORS middleware — restrict to the local Studio UI origin only.
// A wildcard (`*`) origin combined with the command-executing endpoints below
// lets any web page the developer visits drive requests into this server
// (drive-by RCE). Only allow the same-origin UI served from localhost:PORT.
const ALLOWED_ORIGINS = new Set([
`http://localhost:${PORT}`,
`http://127.0.0.1:${PORT}`,
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.header('Access-Control-Allow-Origin', origin);
}
res.header('Vary', 'Origin');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
// Reject cross-origin requests outright for the state-changing endpoints.
if (origin && !ALLOWED_ORIGINS.has(origin)) {
return res.status(403).json({ success: false, error: 'Cross-origin request rejected' });
}
if (req.method === 'OPTIONS') {
res.sendStatus(200);
} else {
next();
}
});
// JSON parsing middleware
app.use(express.json());
// Store active tasks
const activeTasks = new Map();
// Serve the sandbox interface at root
app.get('/', (req, res) => {
// Try local file first (when running from npm package)
const localPath = path.join(__dirname, 'sandbox-interface.html');
// Fallback to docs folder (when running from source)
const docsPath = path.join(__dirname, '../../docs/sandbox-interface.html');
if (fs.existsSync(localPath)) {
res.sendFile(localPath);
} else if (fs.existsSync(docsPath)) {
res.sendFile(docsPath);
} else {
res.status(404).send(`
<html>
<body style="font-family: system-ui; padding: 40px; background: #0f0f0f; color: #e0e0e0;">
<h1>❌ Sandbox Interface Not Found</h1>
<p>The sandbox-interface.html file could not be found.</p>
<p>Please reinstall the package or check your installation.</p>
<pre style="background: #1a1a1a; padding: 10px; border-radius: 5px;">
Tried paths:
- ${localPath}
- ${docsPath}
</pre>
</body>
</html>
`);
}
});
// Serve static files for CSS, JS, etc. (but not index.html at root)
app.use('/css', express.static(path.join(__dirname, '../../docs/css')));
app.use('/js', express.static(path.join(__dirname, '../../docs/js')));
app.use('/assets', express.static(path.join(__dirname, '../../docs/assets')));
// Serve components.json for agent autocomplete
app.get('/components.json', (req, res) => {
const componentsPath = path.join(__dirname, '../../docs/components.json');
if (fs.existsSync(componentsPath)) {
res.sendFile(componentsPath);
} else {
res.status(404).json({ error: 'Components file not found' });
}
});
// API endpoint to execute task (local or cloud)
app.post('/api/execute', async (req, res) => {
const { prompt, mode = 'local', agent = 'development-team/frontend-developer' } = req.body;
if (!prompt || prompt.trim().length < 10) {
return res.status(400).json({
success: false,
error: 'Please provide a detailed prompt (at least 10 characters)'
});
}
const taskId = `task-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
// Create task object
const task = {
id: taskId,
title: prompt.substring(0, 60) + (prompt.length > 60 ? '...' : ''),
prompt: prompt.trim(),
agent: agent,
mode: mode,
status: 'running',
startTime: new Date(),
progress: 0,
output: [],
sandboxId: null
};
activeTasks.set(taskId, task);
// Execute based on mode
if (mode === 'cloud') {
executeE2BTask(task);
} else {
executeLocalTask(task);
}
res.json({
success: true,
taskId: taskId,
message: 'Task started successfully'
});
});
// API endpoint to get task status
app.get('/api/task/:taskId', (req, res) => {
const task = activeTasks.get(req.params.taskId);
if (!task) {
return res.status(404).json({
success: false,
error: 'Task not found'
});
}
res.json({
success: true,
task: {
id: task.id,
title: task.title,
status: task.status,
progress: task.progress,
output: task.output.join('\\n'),
startTime: task.startTime,
endTime: task.endTime,
sandboxId: task.sandboxId
}
});
});
// API endpoint to get all tasks
app.get('/api/tasks', (req, res) => {
const tasks = Array.from(activeTasks.values()).map(task => ({
id: task.id,
title: task.title,
status: task.status,
progress: task.progress,
startTime: task.startTime,
endTime: task.endTime,
sandboxId: task.sandboxId,
output: task.output.slice(-3).join('\\n') // Last 3 lines for preview
}));
res.json({
success: true,
tasks: tasks.sort((a, b) => new Date(b.startTime) - new Date(a.startTime))
});
});
// API endpoint to install agent
app.post('/api/install-agent', async (req, res) => {
const { agentName } = req.body;
if (!agentName) {
return res.status(400).json({
success: false,
error: 'Agent name is required'
});
}
// SECURITY: agent names are `category/name` slugs. Reject anything else so a
// value like "x; rm -rf ~" can never reach the child process.
if (!/^[A-Za-z0-9._/-]+$/.test(agentName)) {
return res.status(400).json({
success: false,
error: 'Invalid agent name'
});
}
try {
console.log(chalk.blue('🔧 Installing agent:'), chalk.cyan(agentName));
// SECURITY: shell:false (default) keeps agentName as a single argv entry —
// no shell parses it, so metacharacters cannot inject commands.
const child = spawn(NPX_CMD, ['claude-code-templates@latest', '--agent', agentName, '--yes'], {
cwd: process.cwd(),
stdio: ['pipe', 'pipe', 'pipe']
});
let output = [];
let error = [];
child.stdout.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
output.push(...lines);
});
child.stderr.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
error.push(...lines);
});
child.on('close', (code) => {
if (code === 0) {
console.log(chalk.green('✅ Agent installed successfully:'), chalk.cyan(agentName));
res.json({
success: true,
message: `Agent ${agentName} installed successfully`,
output: output.join('\n')
});
} else {
console.error(chalk.red('❌ Agent installation failed:'), chalk.cyan(agentName));
res.status(500).json({
success: false,
error: `Failed to install agent ${agentName}`,
output: error.join('\n')
});
}
});
child.on('error', (error) => {
console.error(chalk.red('❌ Agent installation error:'), error.message);
res.status(500).json({
success: false,
error: `Installation error: ${error.message}`
});
});
} catch (error) {
console.error(chalk.red('❌ Failed to start agent installation:'), error.message);
res.status(500).json({
success: false,
error: `Failed to start installation: ${error.message}`
});
}
});
async function checkAndInstallAgent(agentName, task) {
// Check if agent exists in .claude directory
const claudeDir = path.join(process.cwd(), '.claude');
const agentPath = path.join(claudeDir, 'agents', `${agentName}.md`);
if (fs.existsSync(agentPath)) {
return true; // Agent already exists
}
task.output.push(`🔧 Agent ${agentName} not found locally. Installing...`);
// SECURITY: reject anything that is not a plain `category/name` slug.
if (!/^[A-Za-z0-9._/-]+$/.test(agentName)) {
task.output.push(`❌ Invalid agent name: ${agentName}`);
return Promise.resolve(false);
}
return new Promise((resolve, reject) => {
// SECURITY: shell:false (default) — agentName stays a single argv entry.
const child = spawn(NPX_CMD, ['claude-code-templates@latest', '--agent', agentName, '--yes'], {
cwd: process.cwd(),
stdio: ['pipe', 'pipe', 'pipe']
});
child.stdout.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
lines.forEach(line => {
task.output.push(`📦 ${line}`);
});
});
child.stderr.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
lines.forEach(line => {
task.output.push(`⚠️ ${line}`);
});
});
child.on('close', (code) => {
if (code === 0) {
task.output.push(`✅ Agent ${agentName} installed successfully`);
resolve(true);
} else {
task.output.push(`❌ Failed to install agent ${agentName}`);
resolve(false);
}
});
child.on('error', (error) => {
task.output.push(`❌ Installation error: ${error.message}`);
resolve(false);
});
});
}
async function executeE2BTask(task) {
try {
task.output.push('🚀 Initializing E2B sandbox execution...');
task.progress = 10;
// Check and install agent if needed
if (task.agent !== 'development-team/frontend-developer') {
task.output.push(`🔍 Checking agent: ${task.agent}`);
const agentInstalled = await checkAndInstallAgent(task.agent, task);
if (!agentInstalled) {
task.status = 'failed';
task.endTime = new Date();
task.output.push(`❌ Could not install required agent: ${task.agent}`);
return;
}
task.progress = 15;
}
const e2bLauncherPath = path.join(__dirname, '../components/sandbox/e2b/e2b-launcher.py');
const agentParam = `--agent=${task.agent} --yes`;
// Build command arguments
const args = [
e2bLauncherPath,
task.prompt,
agentParam
];
// Add API keys from environment if available
if (process.env.E2B_API_KEY) {
args.push(process.env.E2B_API_KEY);
}
if (process.env.ANTHROPIC_API_KEY) {
args.push(process.env.ANTHROPIC_API_KEY);
}
task.output.push('🔧 Starting Python E2B launcher...');
task.progress = 20;
// Execute the E2B launcher from the user's working directory
const child = spawn('python3', args, {
cwd: process.cwd(), // This ensures it runs from where the user executed the command
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env, // Pass all environment variables including loaded ones
PATH: process.env.PATH
}
});
// Handle stdout
child.stdout.on('data', (data) => {
const lines = data.toString().split('\\n').filter(line => line.trim());
lines.forEach(line => {
task.output.push(line);
// Update progress based on output
if (line.includes('Sandbox created:')) {
task.sandboxId = line.split('Sandbox created: ')[1] || 'unknown';
task.progress = 40;
} else if (line.includes('Installing')) {
task.progress = 60;
} else if (line.includes('Executing Claude Code')) {
task.progress = 80;
} else if (line.includes('Downloaded:')) {
task.progress = 95;
} else if (line.includes('Execution completed successfully')) {
task.progress = 100;
task.status = 'completed';
task.endTime = new Date();
}
});
});
// Handle stderr
child.stderr.on('data', (data) => {
const lines = data.toString().split('\\n').filter(line => line.trim());
lines.forEach(line => {
task.output.push(`⚠️ ${line}`);
});
});
// Handle process exit
child.on('close', (code) => {
if (code === 0) {
if (task.status !== 'completed') {
task.status = 'completed';
task.endTime = new Date();
task.progress = 100;
}
task.output.push('✅ Task completed successfully!');
} else {
task.status = 'failed';
task.endTime = new Date();
// Check if it's an API key error
const outputText = task.output.join(' ');
if (outputText.includes('E2B API key is required') || outputText.includes('Anthropic API key is required')) {
task.output.push('❌ Missing API keys! Please add E2B_API_KEY and ANTHROPIC_API_KEY to your .env file');
task.output.push('🔑 Get E2B key: https://e2b.dev/dashboard');
task.output.push('🔑 Get Anthropic key: https://console.anthropic.com');
} else {
task.output.push(`❌ Process exited with code: ${code}`);
}
}
});
// Handle process error
child.on('error', (error) => {
task.status = 'failed';
task.endTime = new Date();
task.output.push(`❌ Execution error: ${error.message}`);
});
} catch (error) {
task.status = 'failed';
task.endTime = new Date();
task.output.push(`❌ Failed to start execution: ${error.message}`);
}
}
async function executeLocalTask(task) {
try {
task.output.push('🖥️ Executing Claude Code locally...');
task.progress = 10;
// Check and install agent if needed
if (task.agent !== 'development-team/frontend-developer') {
task.output.push(`🔍 Checking agent: ${task.agent}`);
const agentInstalled = await checkAndInstallAgent(task.agent, task);
if (!agentInstalled) {
task.status = 'failed';
task.endTime = new Date();
task.output.push(`❌ Could not install required agent: ${task.agent}`);
return;
}
task.progress = 15;
}
task.output.push('🔍 Checking if Claude Code CLI is available...');
// For local execution, we'll include the agent in the prompt if specified
let finalPrompt = task.prompt;
if (task.agent && task.agent !== 'development-team/frontend-developer') {
finalPrompt = `As a ${task.agent.replace('-', ' ')}, ${task.prompt}`;
}
// Execute Claude Code locally with just the prompt.
// SECURITY: never run through a shell. With shell:true, Node joins argv into
// a single `sh -c` string and the attacker-controlled prompt is parsed by the
// shell (command injection). shell:false keeps finalPrompt as a single argv[1].
// Windows: resolve claude.cmd explicitly (CLAUDE_CMD) instead of re-enabling shell:true.
const child = spawn(CLAUDE_CMD, [finalPrompt], {
cwd: process.cwd(),
stdio: ['ignore', 'pipe', 'pipe'], // Ignore stdin to prevent hanging
env: {
...process.env,
PATH: process.env.PATH
},
timeout: 300000 // 5 minute timeout
});
task.output.push('🚀 Claude Code execution started');
task.progress = 30;
// Set up timeout to prevent hanging
const executionTimeout = setTimeout(() => {
task.output.push('⏰ Execution timeout reached (5 minutes)');
task.output.push('💡 This might indicate Claude Code is waiting for input or has hung');
task.status = 'failed';
task.endTime = new Date();
child.kill('SIGTERM');
}, 300000); // 5 minutes
// Handle stdout
child.stdout.on('data', (data) => {
const lines = data.toString().split('\\n').filter(line => line.trim());
lines.forEach(line => {
task.output.push(line);
// Update progress based on output patterns
if (line.includes('Reading') || line.includes('Analyzing')) {
task.progress = Math.min(task.progress + 5, 60);
} else if (line.includes('Writing') || line.includes('Creating')) {
task.progress = Math.min(task.progress + 10, 90);
} else if (line.includes('Done') || line.includes('Complete')) {
task.progress = 95;
}
});
});
// Handle stderr
child.stderr.on('data', (data) => {
const lines = data.toString().split('\\n').filter(line => line.trim());
lines.forEach(line => {
task.output.push(`⚠️ ${line}`);
});
});
// Handle process exit
child.on('close', (code) => {
clearTimeout(executionTimeout); // Clear timeout when process exits
if (code === 0) {
task.status = 'completed';
task.endTime = new Date();
task.progress = 100;
task.output.push('✅ Claude Code execution completed successfully!');
task.output.push('📂 Files were created/modified in your current directory');
} else {
task.status = 'failed';
task.endTime = new Date();
const outputText = task.output.join(' ');
if (outputText.includes('claude: command not found') || outputText.includes('not recognized')) {
task.output.push('❌ Claude Code CLI not found!');
task.output.push('💡 Please install Claude Code CLI first:');
task.output.push('🔗 Visit: https://claude.ai/code');
} else {
task.output.push(`❌ Process exited with code: ${code}`);
}
}
});
// Handle process error
child.on('error', (error) => {
clearTimeout(executionTimeout); // Clear timeout on error
task.status = 'failed';
task.endTime = new Date();
if (error.code === 'ENOENT') {
task.output.push('❌ Claude Code CLI not found in PATH!');
task.output.push('💡 Please install Claude Code CLI first:');
task.output.push('🔗 Visit: https://claude.ai/code');
task.output.push('🔗 Documentation: https://docs.anthropic.com/claude-code');
} else {
task.output.push(`❌ Execution error: ${error.message}`);
}
});
} catch (error) {
task.status = 'failed';
task.endTime = new Date();
task.output.push(`❌ Failed to start local execution: ${error.message}`);
}
}
// Start server — bind to loopback only. This is a local developer tool that
// executes commands; binding to 0.0.0.0 exposes RCE to the whole LAN.
app.listen(PORT, '127.0.0.1', () => {
console.log(chalk.blue('\\n🎨 Claude Code Studio Server'));
console.log(chalk.cyan('═══════════════════════════════════════'));
console.log(chalk.green(`🚀 Server running on http://localhost:${PORT}`));
console.log(chalk.gray('💡 Local and cloud execution interface ready'));
if (hasApiKeys) {
console.log(chalk.green('\\n✅ All API keys are configured and ready!'));
} else {
console.log(chalk.yellow('\\n⚠️ API Keys Status:'));
console.log(chalk.gray(` • E2B_API_KEY: ${process.env.E2B_API_KEY ? 'Found' : 'Missing'}`));
console.log(chalk.gray(` • ANTHROPIC_API_KEY: ${process.env.ANTHROPIC_API_KEY ? 'Found' : 'Missing'}`));
console.log(chalk.yellow(' • Please add these keys to your .env file'));
}
console.log('');
});
module.exports = app;