feat(sandbox): add Node.js code execution support to sandbox

This commit is contained in:
Eternity
2026-01-30 12:08:34 +08:00
parent ee50b25d06
commit 36e0ed15b6
35 changed files with 820 additions and 314 deletions

View File

@@ -0,0 +1,3 @@
from app.core.runners.nodejs.env import release_lib_binary
release_lib_binary(True)

View File

@@ -0,0 +1,124 @@
import asyncio
import ctypes
import os
import shutil
import stat
import tempfile
from pathlib import Path
from app.logger import get_logger
from app.config import get_config
logger = get_logger()
RELEASE_LIB_PATH = "./lib/seccomp_redbear/target/release/libnodejs.so"
LIB_PATH = "/var/sandbox/sandbox-nodejs"
LIB_NAME = "libnodejs.so"
lib = ctypes.CDLL(RELEASE_LIB_PATH)
lib.get_lib_version_static.restype = ctypes.c_char_p
lib.get_lib_feature_static.restype = ctypes.c_char_p
logger.info(f"Seccomp Env: nodejs, "
f"Seccomp Feature: {lib.get_lib_feature_static().decode('utf-8')}, "
f"Seccomp Version: {lib.get_lib_version_static().decode('utf-8')}")
try:
with open(RELEASE_LIB_PATH, "rb") as f:
_NODEJS_LIB = f.read()
except:
logger.critical("failed to load nodejs lib")
raise
def check_lib_avaiable():
return os.path.exists(os.path.join(LIB_PATH, LIB_NAME))
def release_lib_binary(force_remove: bool):
logger.info("init runtime enviroment")
lib_file = os.path.join(LIB_PATH, LIB_NAME)
if os.path.exists(lib_file):
if force_remove:
try:
os.remove(lib_file)
except OSError:
logger.critical(f"failed to remove {os.path.join(LIB_PATH, LIB_NAME)}")
raise
try:
os.makedirs(LIB_PATH, mode=0o755, exist_ok=True)
except OSError:
logger.critical(f"failed to create {LIB_PATH}")
raise
try:
with open(lib_file, "wb") as f:
f.write(_NODEJS_LIB)
os.chmod(lib_file, 0o755)
except OSError:
logger.critical(f"failed to write {lib_file}")
raise
else:
try:
os.makedirs(LIB_PATH, mode=0o755, exist_ok=True)
except OSError:
logger.critical(f"failed to create {LIB_PATH}")
raise
try:
with open(lib_file, "wb") as f:
f.write(_NODEJS_LIB)
os.chmod(lib_file, 0o755)
except OSError:
logger.critical(f"failed to write {lib_file}")
raise
logger.info("nodejs runner environment initialized")
async def prepare_nodejs_dependencies_env():
config = get_config()
with tempfile.TemporaryDirectory(dir="/") as root_path:
root = Path(root_path)
env_sh = root / "env.sh"
with open("script/env.sh") as f:
env_sh.write_text(f.read())
env_sh.chmod(env_sh.stat().st_mode | stat.S_IXUSR)
shutil.copytree("dependencies/nodejs", os.path.join(LIB_PATH, "node_temp"), dirs_exist_ok=True)
for root, dirs, files in os.walk(os.path.join(LIB_PATH, "node_temp")):
for d in dirs:
os.chmod(os.path.join(root, d), 0o755)
for f in files:
os.chmod(os.path.join(root, f), 0o444)
for lib_path in config.nodejs_lib_paths:
lib_path = Path(lib_path)
if not lib_path.exists():
logger.warning("nodejs lib path %s is not available", lib_path)
continue
cmd = [
"bash",
str(env_sh),
str(lib_path),
str(LIB_PATH),
]
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
retcode = process.returncode
if retcode != 0:
logger.error(
f"create env error for file {lib_path}: retcode={retcode}, stderr={stderr.decode()}"
)

View File

@@ -0,0 +1,138 @@
"""Nodejs code runner"""
import asyncio
import os
import uuid
from typing import Optional
from app.core.executor import CodeExecutor, ExecutionResult
from app.core.runners.nodejs.env import check_lib_avaiable, release_lib_binary, LIB_PATH
from app.logger import get_logger
from app.models import RunnerOptions
# Nodejs sandbox prescript template
with open("app/core/runners/nodejs/prescript.js") as f:
NODEJS_PRESCRIPT = f.read()
logger = get_logger()
class NodejsRunner(CodeExecutor):
"""Node.js code runner with security isolation"""
def __init__(self):
super().__init__()
@staticmethod
def init_environment(code: str, preload: str) -> str:
if not check_lib_avaiable():
release_lib_binary(False)
code_file_name = uuid.uuid4().hex.replace("-", "_")
script = NODEJS_PRESCRIPT.replace("{{preload}}", preload, 1)
eval_code = f"eval(Buffer.from('{code}', 'base64').toString('utf-8'))"
script = script.replace("{{code}}", eval_code, 1)
code_path = f"{LIB_PATH}/node_temp/tmp/{code_file_name}.js"
try:
os.makedirs(os.path.dirname(code_path), mode=0o755, exist_ok=True)
with open(code_path, "w", encoding="utf-8") as f:
f.write(script)
os.chmod(code_path, 0o755)
except OSError as e:
raise RuntimeError(f"Failed to write {code_path}") from e
return code_path
async def run(
self,
code: str,
options: RunnerOptions,
preload: str = "",
timeout: Optional[int] = None
) -> ExecutionResult:
"""Run Python code in sandbox
Args:
options:
code: Base64 encoded encrypted code
preload: Preload code to execute before main code
timeout: Execution timeout in seconds
Returns:
ExecutionResult with stdout, stderr, and exit code
"""
config = self.config
if timeout is None:
timeout = config.worker_timeout
# Check if preload is allowed
if not preload or not config.enable_preload:
preload = ""
script_path = self.init_environment(code, preload)
try:
# Setup environment
env = {
"UV_USE_IO_URING": "0"
}
# Add proxy settings if configured
if config.proxy.socks5:
env["HTTPS_PROXY"] = config.proxy.socks5
env["HTTP_PROXY"] = config.proxy.socks5
elif config.proxy.https or config.proxy.http:
if config.proxy.https:
env["HTTPS_PROXY"] = config.proxy.https
if config.proxy.http:
env["HTTP_PROXY"] = config.proxy.http
# Add allowed syscalls if configured
if config.allowed_syscalls:
env["ALLOWED_SYSCALLS"] = ",".join(map(str, config.allowed_syscalls))
process = await asyncio.create_subprocess_exec(
config.nodejs_path,
script_path,
LIB_PATH,
str(config.sandbox_uid),
str(config.sandbox_gid),
options.model_dump_json(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
cwd=LIB_PATH
)
# Wait for completion with timeout
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout
)
return ExecutionResult(
stdout=stdout.decode('utf-8', errors='replace'),
stderr=stderr.decode('utf-8', errors='replace'),
exit_code=process.returncode
)
except asyncio.TimeoutError:
# Kill process on timeout
try:
process.kill()
await process.wait()
except:
pass
return ExecutionResult(
stdout="",
stderr="Execution timeout",
exit_code=-1,
)
finally:
# Cleanup temporary file
self.cleanup_temp_file(script_path)

View File

@@ -0,0 +1,31 @@
let argv = process.argv
let koffi = require('koffi')
process.chdir(argv[2])
let lib = koffi.load("./libnodejs.so")
/** @type {(uid: number, gid: number, enableNetwork: boolean) => number} */
let initSeccomp = lib.func('int init_seccomp(int, int, bool)')
let uid = parseInt(argv[3])
let gid = parseInt(argv[4])
let options = JSON.parse(argv[5])
let seccomp_init = initSeccomp(uid, gid, options['enable_network'])
if (seccomp_init !== 0) {
throw `code executor err - ${seccomp_init}`
}
delete process.argv
argv = undefined
koffi = undefined
lib = undefined
initSeccomp = undefined
uid = undefined
gid = undefined
options = undefined
seccomp_init = undefined
{{code}}