File size: 8,400 Bytes
28a08e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
"""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}