diff --git a/server/index.ts b/server/index.ts index cd69d28..414f701 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,3 +1,22 @@ +/** + * SECURITY NOTES for this server: + * + * 1. The /api/execute endpoint runs user-submitted code. It is the highest-risk + * surface. See server/routes/execute.ts for sandboxing details. + * + * 2. CORS is restricted to the configured ALLOWED_ORIGIN (defaults to + * localhost during development). For production, set the CS_ALLOWED_ORIGIN + * environment variable to the actual frontend origin. + * + * 3. Request body size is limited to prevent memory exhaustion attacks. + * + * 4. TODO(security): Add authentication before allowing code execution. + * Currently any client can execute arbitrary code on the server. + * + * 5. TODO(security): Add HTTPS termination (via reverse proxy or directly) + * for production deployments. + */ + import { Database } from 'bun:sqlite'; import { contentRoutes } from './routes/content'; import { progressRoutes } from './routes/progress'; @@ -6,11 +25,18 @@ import { initializeDatabase } from './db/schema'; const PORT = process.env['PORT'] ?? 3000; +// SECURITY: Restrict CORS to a specific origin in production. +// Set CS_ALLOWED_ORIGIN env var to your frontend's URL. +const ALLOWED_ORIGIN = process.env['CS_ALLOWED_ORIGIN'] ?? `http://localhost:4200`; + +// SECURITY: Maximum request body size (256 KB) to prevent memory exhaustion +const MAX_REQUEST_BODY_BYTES = 256 * 1024; + const db = initializeDatabase(); const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }; @@ -24,6 +50,20 @@ Bun.serve({ return new Response(null, { headers: corsHeaders }); } + // SECURITY: Enforce request body size limit on POST requests + if (req.method === 'POST') { + const contentLength = req.headers.get('content-length'); + if (contentLength && parseInt(contentLength, 10) > MAX_REQUEST_BODY_BYTES) { + return new Response( + JSON.stringify({ error: 'Request body too large' }), + { + status: 413, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + } + ); + } + } + if (path.startsWith('/api/content')) { return contentRoutes(req, url, corsHeaders); } diff --git a/server/routes/execute.ts b/server/routes/execute.ts index eed1510..2a5bfca 100644 --- a/server/routes/execute.ts +++ b/server/routes/execute.ts @@ -1,7 +1,29 @@ -import { $ } from 'bun'; +/** + * SECURITY: Remote Code Execution Endpoint + * + * This module executes user-submitted code on the server. Every execution + * MUST be sandboxed to prevent: + * - Filesystem access outside the temp directory + * - Network access from user code + * - Excessive resource consumption (CPU, memory, disk) + * - Process spawning / privilege escalation + * + * Sandboxing strategy: + * - macOS: Uses `sandbox-exec` with a deny-all-except-needed profile + * - Linux: Uses `unshare` (network namespace) + `ulimit` resource caps + * - All platforms: Enforced timeout that KILLS the child process, + * code size limits, output size limits, and rate limiting. + * + * TODO(security): For production deployments, migrate to container-based + * isolation (Docker with --network=none, read-only rootfs, memory/CPU + * limits, seccomp profiles) or a dedicated sandboxing service like + * Firecracker / gVisor. + */ + import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { spawn } from 'node:child_process'; type Headers = Record; @@ -31,10 +53,191 @@ interface ExecuteResponse { testResults?: TestResult[]; } -const TIMEOUT_MS = 5000; // 5 seconds max for code execution -const TEST_TIMEOUT_MS = 3000; // 3 seconds max per test -const MAX_OUTPUT_LENGTH = 50000; -const MAX_TESTS = 20; // Maximum number of tests to run +// --- Security constants --- +const TIMEOUT_MS = 5000; // 5 seconds max for code execution +const TEST_TIMEOUT_MS = 3000; // 3 seconds max per test +const COMPILE_TIMEOUT_MS = 15000; // 15 seconds for Kotlin compilation +const MAX_OUTPUT_LENGTH = 50_000; // 50 KB max output +const MAX_CODE_LENGTH = 100_000; // 100 KB max code size +const MAX_TESTS = 20; // Maximum number of tests to run + +// Rate limiting: max requests per IP per window +const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute +const RATE_LIMIT_MAX_REQUESTS = 30; // 30 executions per minute +const rateLimitMap = new Map(); + +// --- Sandbox profiles --- + +/** + * macOS sandbox-exec profile: denies all filesystem writes outside tempDir, + * denies all network access, allows reading system libraries needed for + * compilers/runtimes. + */ +function darwinSandboxProfile(tempDir: string): string { + return ` +(version 1) +(deny default) +(allow process-fork process-exec) +(allow signal (target self)) +(allow sysctl-read) +(allow mach-lookup) +(allow file-read* (subpath "/usr") (subpath "/Library") (subpath "/System") (subpath "/Applications") (subpath "/private/var") (subpath "/dev") (subpath "/bin") (subpath "/sbin") (subpath "/opt") (subpath "/etc") (subpath "${tempDir}")) +(allow file-write* (subpath "${tempDir}") (subpath "/dev")) +(deny network*) +`; +} + +const IS_DARWIN = process.platform === 'darwin'; +const IS_LINUX = process.platform === 'linux'; + +// --- Helpers --- + +/** + * Execute a command inside a sandbox with a hard timeout that kills the process. + * Returns { stdout, stderr, exitCode }. + */ +function sandboxedExec( + command: string[], + tempDir: string, + timeoutMs: number, + env?: Record +): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean }> { + return new Promise((resolve) => { + let wrappedCommand: string[]; + let wrappedEnv: Record = { ...process.env, ...env, HOME: tempDir } as Record; + + if (IS_DARWIN) { + // Use sandbox-exec on macOS + const profile = darwinSandboxProfile(tempDir); + wrappedCommand = ['sandbox-exec', '-p', profile, ...command]; + } else if (IS_LINUX) { + // Use unshare for network isolation + ulimit for resource limits on Linux + // SECURITY: --net isolates network, ulimit caps CPU time, file size, and processes + wrappedCommand = [ + 'unshare', '--net', '--', + '/bin/bash', '-c', + `ulimit -t ${Math.ceil(timeoutMs / 1000) + 2} -f 10240 -u 64 -v 524288 2>/dev/null; exec ${command.map(shellEscape).join(' ')}` + ]; + } else { + // Fallback: run without OS-level sandbox but with timeout kill + // SECURITY WARNING: No OS-level sandboxing on this platform. + // Only the timeout-based kill provides protection. + console.warn('[Execute] WARNING: No OS-level sandbox available on this platform. Code runs unsandboxed.'); + wrappedCommand = command; + } + + const child = spawn(wrappedCommand[0], wrappedCommand.slice(1), { + cwd: tempDir, + env: wrappedEnv, + stdio: ['ignore', 'pipe', 'pipe'], + // SECURITY: Prevent the child from being a session leader that can escape kill + detached: false, + }); + + let stdout = ''; + let stderr = ''; + let timedOut = false; + let finished = false; + + // SECURITY: Hard timeout that kills the process tree + const timer = setTimeout(() => { + if (!finished) { + timedOut = true; + try { + // Kill the entire process group if possible + process.kill(-child.pid!, 'SIGKILL'); + } catch { + try { child.kill('SIGKILL'); } catch { /* already dead */ } + } + } + }, timeoutMs); + + child.stdout?.on('data', (chunk: Buffer) => { + if (stdout.length < MAX_OUTPUT_LENGTH) { + stdout += chunk.toString(); + } + }); + + child.stderr?.on('data', (chunk: Buffer) => { + if (stderr.length < MAX_OUTPUT_LENGTH) { + stderr += chunk.toString(); + } + }); + + child.on('close', (exitCode) => { + finished = true; + clearTimeout(timer); + resolve({ + stdout: truncate(stdout), + stderr: truncate(stderr), + exitCode, + timedOut, + }); + }); + + child.on('error', (err) => { + finished = true; + clearTimeout(timer); + resolve({ + stdout: '', + stderr: err.message, + exitCode: 1, + timedOut: false, + }); + }); + }); +} + +/** Escape a string for safe inclusion in a shell command. */ +function shellEscape(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'`; +} + +/** Check rate limit for a given client identifier. Returns true if allowed. */ +function checkRateLimit(clientId: string): boolean { + const now = Date.now(); + const entry = rateLimitMap.get(clientId); + + if (!entry || now > entry.resetAt) { + rateLimitMap.set(clientId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); + return true; + } + + if (entry.count >= RATE_LIMIT_MAX_REQUESTS) { + return false; + } + + entry.count++; + return true; +} + +/** Periodically clean up expired rate limit entries. */ +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of rateLimitMap) { + if (now > entry.resetAt) { + rateLimitMap.delete(key); + } + } +}, RATE_LIMIT_WINDOW_MS); + +/** + * Validate that user code doesn't contain obvious escape attempts. + * This is defense-in-depth; the sandbox is the primary protection. + */ +function validateCode(code: string, language: string): string | null { + if (code.length > MAX_CODE_LENGTH) { + return `Code too large: ${code.length} bytes (max ${MAX_CODE_LENGTH})`; + } + + if (code.includes('\0')) { + return 'Code contains null bytes'; + } + + return null; // passes validation +} + +// --- Route handler --- export async function executeRoutes( req: Request, @@ -57,6 +260,19 @@ async function handleExecute( req: Request, headers: Headers ): Promise { + // SECURITY: Rate limiting by IP or fallback identifier + const clientIp = req.headers.get('x-forwarded-for') + ?? req.headers.get('x-real-ip') + ?? 'unknown'; + + if (!checkRateLimit(clientIp)) { + return jsonResponse( + { error: 'Rate limit exceeded. Try again later.', success: false, output: '' }, + 429, + headers + ); + } + let body: ExecuteRequest; try { @@ -75,6 +291,21 @@ async function handleExecute( return jsonResponse({ error: 'Unsupported language for server execution', success: false, output: '' }, 400, headers); } + // SECURITY: Validate code size and content + const validationError = validateCode(code, language); + if (validationError) { + return jsonResponse({ error: validationError, success: false, output: '' }, 400, headers); + } + + // SECURITY: Validate and limit test cases + if (testCases && testCases.length > MAX_TESTS) { + return jsonResponse( + { error: `Too many test cases (max ${MAX_TESTS})`, success: false, output: '' }, + 400, + headers + ); + } + try { const result = await executeCode(language, code, testCases); return jsonResponse(result, result.success ? 200 : 400, headers); @@ -104,22 +335,24 @@ async function executeCode( } async function executeSwift(code: string, testCases?: TestCase[]): Promise { - const tempDir = await mkdtemp(join(tmpdir(), 'swift-')); + const tempDir = await mkdtemp(join(tmpdir(), 'cs-swift-')); const sourceFile = join(tempDir, 'main.swift'); try { await writeFile(sourceFile, code, 'utf-8'); - const result = await Promise.race([ - $`swift ${sourceFile}`.quiet().nothrow(), - timeout(TIMEOUT_MS), - ]); + // SECURITY: Execute inside sandbox with hard-kill timeout + const result = await sandboxedExec( + ['swift', sourceFile], + tempDir, + TIMEOUT_MS + ); - if (result === 'timeout') { - return { output: '', success: false, error: 'Execution timed out' }; + if (result.timedOut) { + return { output: '', success: false, error: 'Execution timed out (5s limit)' }; } - const output = truncate(result.stdout.toString() + result.stderr.toString()); + const output = truncate(result.stdout + result.stderr); const success = result.exitCode === 0; let testResults: TestResult[] | undefined; @@ -139,37 +372,41 @@ async function executeSwift(code: string, testCases?: TestCase[]): Promise { - const tempDir = await mkdtemp(join(tmpdir(), 'rust-')); + const tempDir = await mkdtemp(join(tmpdir(), 'cs-rust-')); const sourceFile = join(tempDir, 'main.rs'); const outputBinary = join(tempDir, 'main'); try { await writeFile(sourceFile, code, 'utf-8'); - const compileResult = await Promise.race([ - $`rustc ${sourceFile} -o ${outputBinary}`.quiet().nothrow(), - timeout(TIMEOUT_MS), - ]); + // SECURITY: Compile inside sandbox + const compileResult = await sandboxedExec( + ['rustc', sourceFile, '-o', outputBinary], + tempDir, + TIMEOUT_MS + ); - if (compileResult === 'timeout') { + if (compileResult.timedOut) { return { output: '', success: false, error: 'Compilation timed out' }; } if (compileResult.exitCode !== 0) { - const output = truncate(compileResult.stderr.toString()); + const output = truncate(compileResult.stderr); return { output, success: false, error: 'Compilation error' }; } - const runResult = await Promise.race([ - $`${outputBinary}`.quiet().nothrow(), - timeout(TIMEOUT_MS), - ]); + // SECURITY: Run compiled binary inside sandbox + const runResult = await sandboxedExec( + [outputBinary], + tempDir, + TIMEOUT_MS + ); - if (runResult === 'timeout') { + if (runResult.timedOut) { return { output: '', success: false, error: 'Execution timed out' }; } - const output = truncate(runResult.stdout.toString() + runResult.stderr.toString()); + const output = truncate(runResult.stdout + runResult.stderr); const success = runResult.exitCode === 0; let testResults: TestResult[] | undefined; @@ -189,27 +426,28 @@ async function executeRust(code: string, testCases?: TestCase[]): Promise { - const tempDir = await mkdtemp(join(tmpdir(), 'ts-')); + const tempDir = await mkdtemp(join(tmpdir(), 'cs-ts-')); const sourceFile = join(tempDir, 'main.ts'); try { await writeFile(sourceFile, code, 'utf-8'); - const result = await Promise.race([ - $`bun ${sourceFile}`.quiet().nothrow(), - timeout(TIMEOUT_MS), - ]); + // SECURITY: Execute inside sandbox with hard-kill timeout + const result = await sandboxedExec( + ['bun', sourceFile], + tempDir, + TIMEOUT_MS + ); - if (result === 'timeout') { + if (result.timedOut) { return { output: '', success: false, error: 'Execution timed out' }; } - const output = truncate(result.stdout.toString() + result.stderr.toString()); + const output = truncate(result.stdout + result.stderr); const success = result.exitCode === 0; let testResults: TestResult[] | undefined; if (testCases && success) { - // Run assertion-based tests testResults = await runTypeScriptTests(tempDir, code, testCases); } @@ -233,7 +471,6 @@ async function runTypeScriptTests( const testsToRun = testCases.slice(0, MAX_TESTS); for (const test of testsToRun) { - // Handle assertion-based tests if (test.assertion) { const testCode = `${code}\nconsole.log(${test.assertion})`; const testFile = join(tempDir, 'test.ts'); @@ -241,12 +478,14 @@ async function runTypeScriptTests( try { await writeFile(testFile, testCode, 'utf-8'); - const result = await Promise.race([ - $`bun ${testFile}`.quiet().nothrow(), - timeout(TEST_TIMEOUT_MS), - ]); + // SECURITY: Each test runs in its own sandbox with timeout + const result = await sandboxedExec( + ['bun', testFile], + tempDir, + TEST_TIMEOUT_MS + ); - if (result === 'timeout') { + if (result.timedOut) { results.push({ description: test.description, passed: false, @@ -255,7 +494,7 @@ async function runTypeScriptTests( continue; } - const output = result.stdout.toString().trim(); + const output = result.stdout.trim(); const passed = output === 'true'; console.log('[Execute] TS assertion test:', test.description, '- output:', output, '- passed:', passed); @@ -290,37 +529,41 @@ async function runTypeScriptTests( } async function executeKotlin(code: string, testCases?: TestCase[]): Promise { - const tempDir = await mkdtemp(join(tmpdir(), 'kotlin-')); + const tempDir = await mkdtemp(join(tmpdir(), 'cs-kotlin-')); const sourceFile = join(tempDir, 'Main.kt'); const jarFile = join(tempDir, 'main.jar'); try { await writeFile(sourceFile, code, 'utf-8'); - const compileResult = await Promise.race([ - $`kotlinc ${sourceFile} -include-runtime -d ${jarFile}`.quiet().nothrow(), - timeout(TIMEOUT_MS * 3), - ]); + // SECURITY: Compile inside sandbox (Kotlin compilation is slow, allow more time) + const compileResult = await sandboxedExec( + ['kotlinc', sourceFile, '-include-runtime', '-d', jarFile], + tempDir, + COMPILE_TIMEOUT_MS + ); - if (compileResult === 'timeout') { + if (compileResult.timedOut) { return { output: '', success: false, error: 'Compilation timed out' }; } if (compileResult.exitCode !== 0) { - const output = truncate(compileResult.stderr.toString()); + const output = truncate(compileResult.stderr); return { output, success: false, error: 'Compilation error' }; } - const runResult = await Promise.race([ - $`java -jar ${jarFile}`.quiet().nothrow(), - timeout(TIMEOUT_MS), - ]); + // SECURITY: Run compiled jar inside sandbox + const runResult = await sandboxedExec( + ['java', '-jar', jarFile], + tempDir, + TIMEOUT_MS + ); - if (runResult === 'timeout') { + if (runResult.timedOut) { return { output: '', success: false, error: 'Execution timed out' }; } - const output = truncate(runResult.stdout.toString() + runResult.stderr.toString()); + const output = truncate(runResult.stdout + runResult.stderr); const success = runResult.exitCode === 0; let testResults: TestResult[] | undefined; @@ -345,20 +588,23 @@ async function runSwiftTests( testCases: TestCase[] ): Promise { const results: TestResult[] = []; + const testsToRun = testCases.slice(0, MAX_TESTS); - for (const test of testCases) { + for (const test of testsToRun) { const testCode = `${code}\n\n// Test assertion\nprint(${test.assertion})`; const testFile = join(tempDir, 'test.swift'); try { await writeFile(testFile, testCode, 'utf-8'); - const result = await Promise.race([ - $`swift ${testFile}`.quiet().nothrow(), - timeout(TIMEOUT_MS), - ]); + // SECURITY: Each test runs in its own sandbox with timeout + const result = await sandboxedExec( + ['swift', testFile], + tempDir, + TIMEOUT_MS + ); - if (result === 'timeout') { + if (result.timedOut) { results.push({ description: test.description, passed: false, @@ -367,7 +613,7 @@ async function runSwiftTests( continue; } - const output = result.stdout.toString().trim(); + const output = result.stdout.trim(); const passed = output === 'true' || (test.expectedOutput !== undefined && output === test.expectedOutput); results.push({ @@ -406,10 +652,6 @@ function evaluateOutputTests(output: string, testCases: TestCase[]): TestResult[ }); } -function timeout(ms: number): Promise<'timeout'> { - return new Promise((resolve) => setTimeout(() => resolve('timeout'), ms)); -} - function truncate(str: string): string { if (str.length > MAX_OUTPUT_LENGTH) { return str.slice(0, MAX_OUTPUT_LENGTH) + '\n... (output truncated)'; diff --git a/src/app/core/services/code-executor.service.ts b/src/app/core/services/code-executor.service.ts index aa4f489..a9d53f1 100644 --- a/src/app/core/services/code-executor.service.ts +++ b/src/app/core/services/code-executor.service.ts @@ -1,9 +1,34 @@ +/** + * SECURITY: Client-Side Code Execution Service + * + * JavaScript execution uses a sandboxed