"""backend/js_sandbox.py — P20-JS-SANDBOX: Secure JS/TS execution. Security layers (in order): 1. Blocklist pre-filter (_JS_BLOCKED_RE) — blocks obvious CJS dangerous patterns 2. Deno sandbox (primary, if available): OS-level deny-all for fs/net/env/subprocess 3. Node vm fallback: vm.runInContext in isolated Context (no require/process/fs) + --disallow-code-generation-from-strings Deno detection: $DENO_PATH env var, then /data/deno/bin/deno, /usr/local/bin/deno, /home/user/.deno/bin/deno, /root/.deno/bin/deno, then PATH lookup. Why Deno beats blocklist: - Blocklist bypassed via dynamic require, obfuscation, prototype chain attacks - Deno: OS-level deny-all — every permission must be explicitly granted with --allow-* - Node vm: JS-level isolation — sandbox Context has no require/process/fs globals """ import os, asyncio, re, logging from typing import Optional _logger = logging.getLogger("api.js_sandbox") # ── Deno detection ───────────────────────────────────────────────────────── _DENO_CANDIDATES = [ os.environ.get("DENO_PATH", ""), "/data/deno/bin/deno", "/usr/local/bin/deno", "/home/user/.deno/bin/deno", "/root/.deno/bin/deno", "deno", # PATH lookup ] def _find_deno() -> Optional[str]: """Return path to Deno binary if available, else None.""" import shutil for candidate in _DENO_CANDIDATES: if not candidate: continue if os.path.isabs(candidate): if os.path.isfile(candidate) and os.access(candidate, os.X_OK): return candidate else: found = shutil.which(candidate) if found: return found return None _DENO_BIN: Optional[str] = _find_deno() _logger.info("[js_sandbox] Deno: %s", _DENO_BIN or "NOT FOUND — using Node vm fallback") # ── JS/TS blocklist (defense-in-depth — belt, Deno/vm are suspenders) ───── # Kept even when Deno is available: O(1) pre-filter before any subprocess spawn. _JS_BLOCKED_RE = re.compile( r'\brequire\s*\(\s*[\'"]child_process[\'"]\s*\)' r'|\bprocess\.env\b' r'|\bfs\s*\.\s*(read|write|unlink|rm)' r'|\bexecSync\b|\bspawnSync\b' r'|\brequire\s*\(\s*[\'"](?:net|http|https|dgram|cluster|worker_threads)[\'"]' r'\s*\)', re.IGNORECASE, ) # ── Node vm runner script (written to tmpdir per-call) ───────────────────── # Reads user code from argv[2]. vm.createContext: no require/process/fs globals. _NODE_VM_RUNNER = r"""'use strict'; const vm = require('vm'); const fs = require('fs'); const code = fs.readFileSync(process.argv[2], 'utf8'); const sandbox = { console: { log: (...a) => process.stdout.write(a.map(String).join(' ') + '\n'), error: (...a) => process.stderr.write(a.map(String).join(' ') + '\n'), warn: (...a) => process.stderr.write('[warn] ' + a.map(String).join(' ') + '\n'), info: (...a) => process.stdout.write('[info] ' + a.map(String).join(' ') + '\n'), }, Math, JSON, Date, Array, Object, String, Number, Boolean, RegExp, Error, TypeError, RangeError, SyntaxError, ReferenceError, Map, Set, WeakMap, WeakSet, Symbol, BigInt, Promise, parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent, }; const ctx = vm.createContext(sandbox); try { vm.runInContext(code, ctx, { timeout: 10000, filename: 'sandbox.js' }); } catch (e) { process.stderr.write(String(e) + '\n'); process.exit(1); } """ async def run_js_sandbox( code: str, lang: str, tmpdir: str, timeout: float = 12.0, preexec_fn=None, safe_env: Optional[dict] = None, ) -> dict: """Execute JS/TS code in a sandbox. Returns dict with stdout, stderr, returncode. Security: blocklist pre-filter (belt) → Deno deny-all sandbox (primary) → Node vm.createContext (fallback). TypeScript: supported natively by Deno only; Node vm fallback refuses TS. """ lang_norm = lang.lower() is_ts = lang_norm in ("typescript", "ts") # Belt: fast pre-filter — catches obvious CJS/process/fs dangerous patterns bad = _JS_BLOCKED_RE.search(code) if bad: return { "stdout": "", "stderr": f"js_blocked: pattern '{bad.group()}' non permesso nel sandbox", "returncode": 1, } if _DENO_BIN: return await _run_with_deno(code, is_ts, tmpdir, timeout, preexec_fn, safe_env or {}) if is_ts: return { "stdout": "", "stderr": ( "TypeScript sandbox: Deno non disponibile — TS non supportato nel fallback Node vm. " "Installa Deno (https://deno.land) o invia codice in JavaScript." ), "returncode": 1, } return await _run_with_node_vm(code, tmpdir, timeout, preexec_fn, safe_env or {}) async def _run_with_deno( code: str, is_ts: bool, tmpdir: str, timeout: float, preexec_fn, env: dict, ) -> dict: """Primary sandbox: Deno run — default deny-all (no --allow-* flags passed). Deno 1.x+: omitting all --allow-* flags = deny-all for fs/net/env/subprocess. --no-prompt: never prompt interactively for permissions (would hang). """ ext = ".ts" if is_ts else ".js" fname = os.path.join(tmpdir, f"sandbox{ext}") with open(fname, "w") as fh: fh.write(code) # No --allow-* = deny-all by default (all Deno versions). cmd = [_DENO_BIN, "run", "--no-prompt", fname] try: proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=tmpdir, env=env, preexec_fn=preexec_fn, ) try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: try: proc.kill() except Exception: pass await proc.wait() return {"stdout": "", "stderr": f"\u26a0\ufe0f Timeout {timeout:.0f}s (Deno sandbox)", "returncode": -1} return { "stdout": stdout.decode("utf-8", errors="replace")[:8000], "stderr": stderr.decode("utf-8", errors="replace")[:4000], "returncode": proc.returncode, } except Exception as exc: _logger.warning("[js_sandbox/deno] spawn error: %s", exc) return {"stdout": "", "stderr": str(exc)[:300], "returncode": 1} async def _run_with_node_vm( code: str, tmpdir: str, timeout: float, preexec_fn, env: dict, ) -> dict: """Fallback sandbox: Node.js vm.createContext — no require/process/fs in Context. Not an OS-level sandbox (prototype chain escapes exist in theory), but combined with the blocklist pre-filter provides solid defense-in-depth for most threats. """ code_file = os.path.join(tmpdir, "user_code.js") runner_file = os.path.join(tmpdir, "_vm_runner.js") with open(code_file, "w") as fh: fh.write(code) with open(runner_file, "w") as fh: fh.write(_NODE_VM_RUNNER) cmd = [ "node", "--disallow-code-generation-from-strings", runner_file, code_file, ] try: proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=tmpdir, env=env, preexec_fn=preexec_fn, ) try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: try: proc.kill() except Exception: pass await proc.wait() return {"stdout": "", "stderr": f"\u26a0\ufe0f Timeout {timeout:.0f}s (Node vm sandbox)", "returncode": -1} return { "stdout": stdout.decode("utf-8", errors="replace")[:8000], "stderr": stderr.decode("utf-8", errors="replace")[:4000], "returncode": proc.returncode, } except Exception as exc: _logger.warning("[js_sandbox/node-vm] spawn error: %s", exc) return {"stdout": "", "stderr": str(exc)[:300], "returncode": 1}